Agenda, Agendador, Configurações
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* agendaMappers.spec.js
|
||||
*
|
||||
* Testa as funções de mapeamento de dados da agenda:
|
||||
* - mapAgendaEventosToCalendarEvents
|
||||
* - mapAgendaEventosToClinicResourceEvents
|
||||
* - buildNextSessions
|
||||
* - minutesToDuration
|
||||
* - tituloFallback
|
||||
* - calcDefaultSlotDuration
|
||||
* - buildWeeklyBreakBackgroundEvents
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mapAgendaEventosToCalendarEvents,
|
||||
mapAgendaEventosToClinicResourceEvents,
|
||||
buildNextSessions,
|
||||
minutesToDuration,
|
||||
tituloFallback,
|
||||
calcDefaultSlotDuration,
|
||||
buildWeeklyBreakBackgroundEvents,
|
||||
} from '../agendaMappers.js'
|
||||
|
||||
// ─── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function evento (overrides = {}) {
|
||||
return {
|
||||
id: 'ev-1',
|
||||
titulo: 'Sessão Teste',
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
inicio_em: '2026-03-10T09:00:00',
|
||||
fim_em: '2026-03-10T10:00:00',
|
||||
owner_id: 'owner-1',
|
||||
tenant_id: 'tenant-1',
|
||||
patient_id: 'patient-1',
|
||||
modalidade: 'presencial',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── mapAgendaEventosToCalendarEvents ─────────────────────────────────────────
|
||||
|
||||
describe('mapAgendaEventosToCalendarEvents', () => {
|
||||
it('mapeia um evento simples para o shape do FullCalendar', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento()])
|
||||
expect(ev.id).toBe('ev-1')
|
||||
expect(ev.start).toBe('2026-03-10T09:00:00')
|
||||
expect(ev.end).toBe('2026-03-10T10:00:00')
|
||||
expect(ev.extendedProps.tipo).toBe('sessao')
|
||||
expect(ev.extendedProps.status).toBe('agendado')
|
||||
})
|
||||
|
||||
it('filtra rows null/undefined', () => {
|
||||
const result = mapAgendaEventosToCalendarEvents([null, undefined, evento()])
|
||||
expect(result.length).toBe(1)
|
||||
})
|
||||
|
||||
it('retorna array vazio para input vazio', () => {
|
||||
expect(mapAgendaEventosToCalendarEvents([])).toEqual([])
|
||||
expect(mapAgendaEventosToCalendarEvents(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('inclui ícone ✓ no título para status realizado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'realizado' })])
|
||||
expect(ev.title).toContain('✓')
|
||||
})
|
||||
|
||||
it('inclui ícone ✗ no título para status faltou', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
|
||||
expect(ev.title).toContain('✗')
|
||||
})
|
||||
|
||||
it('inclui ícone ∅ no título para status cancelado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
|
||||
expect(ev.title).toContain('∅')
|
||||
})
|
||||
|
||||
it('inclui ícone ↺ no título para status remarcado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
|
||||
expect(ev.title).toContain('↺')
|
||||
})
|
||||
|
||||
it('inclui ícone ↻ para ocorrências de série', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ recurrence_id: 'rule-1', is_occurrence: true })])
|
||||
expect(ev.title).toContain('↻')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status faltou', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
|
||||
expect(ev.backgroundColor).toBe('#ef4444')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status cancelado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
|
||||
expect(ev.backgroundColor).toBe('#f97316')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status remarcado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
|
||||
expect(ev.backgroundColor).toBe('#a855f7')
|
||||
})
|
||||
|
||||
it('usa titulo_custom quando disponível', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ titulo_custom: 'Personalizado' })])
|
||||
expect(ev.title).toContain('Personalizado')
|
||||
})
|
||||
|
||||
it('usa nome do paciente via patients join quando titulo ausente', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
titulo: null,
|
||||
titulo_custom: null,
|
||||
patients: { nome_completo: 'João Silva', avatar_url: null }
|
||||
})])
|
||||
expect(ev.title).toContain('João Silva')
|
||||
})
|
||||
|
||||
it('mapeia patient_id corretamente', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ patient_id: 'p-123' })])
|
||||
expect(ev.extendedProps.patient_id).toBe('p-123')
|
||||
expect(ev.extendedProps.paciente_id).toBe('p-123') // alias
|
||||
})
|
||||
|
||||
it('mapeia recurrence_id e original_date', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
recurrence_id: 'rule-abc',
|
||||
original_date: '2026-03-10',
|
||||
})])
|
||||
expect(ev.extendedProps.recurrence_id).toBe('rule-abc')
|
||||
expect(ev.extendedProps.original_date).toBe('2026-03-10')
|
||||
})
|
||||
|
||||
it('mapeia exception_type', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
exception_type: 'patient_missed',
|
||||
status: 'faltou',
|
||||
})])
|
||||
expect(ev.extendedProps.exception_type).toBe('patient_missed')
|
||||
expect(ev.extendedProps.is_exception).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── mapAgendaEventosToClinicResourceEvents ───────────────────────────────────
|
||||
|
||||
describe('mapAgendaEventosToClinicResourceEvents', () => {
|
||||
it('adiciona resourceId baseado em owner_id', () => {
|
||||
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: 'owner-99' })])
|
||||
expect(ev.resourceId).toBe('owner-99')
|
||||
})
|
||||
|
||||
it('usa terapeuta_id como fallback para resourceId', () => {
|
||||
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: null, terapeuta_id: 'tera-1' })])
|
||||
expect(ev.resourceId).toBe('tera-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildNextSessions ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildNextSessions', () => {
|
||||
it('filtra sessões no passado', () => {
|
||||
const now = new Date('2026-03-10T12:00:00')
|
||||
const rows = [
|
||||
evento({ id: 'past', fim_em: '2026-03-09T10:00:00' }),
|
||||
evento({ id: 'future', fim_em: '2026-03-11T10:00:00' }),
|
||||
]
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe('future')
|
||||
})
|
||||
|
||||
it('inclui sessão cujo fim_em é agora (mesmo ms)', () => {
|
||||
const now = new Date('2026-03-10T10:00:00')
|
||||
const rows = [evento({ fim_em: '2026-03-10T10:00:00' })]
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(1)
|
||||
})
|
||||
|
||||
it('limita a 6 sessões', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const rows = Array.from({ length: 10 }, (_, i) => evento({
|
||||
id: `ev-${i}`,
|
||||
fim_em: `2026-03-${String(i + 10).padStart(2,'0')}T10:00:00`,
|
||||
}))
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(6)
|
||||
})
|
||||
|
||||
it('retorna shape correto', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const [s] = buildNextSessions([evento()], now)
|
||||
expect(s).toMatchObject({
|
||||
id: 'ev-1',
|
||||
title: 'Sessão Teste',
|
||||
startISO: '2026-03-10T09:00:00',
|
||||
endISO: '2026-03-10T10:00:00',
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
})
|
||||
})
|
||||
|
||||
it('mapeia pacienteId de patient_id', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const [s] = buildNextSessions([evento({ patient_id: 'p-999' })], now)
|
||||
expect(s.pacienteId).toBe('p-999')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── minutesToDuration ────────────────────────────────────────────────────────
|
||||
|
||||
describe('minutesToDuration', () => {
|
||||
it('30 minutos → 00:30:00', () => expect(minutesToDuration(30)).toBe('00:30:00'))
|
||||
it('60 minutos → 01:00:00', () => expect(minutesToDuration(60)).toBe('01:00:00'))
|
||||
it('90 minutos → 01:30:00', () => expect(minutesToDuration(90)).toBe('01:30:00'))
|
||||
it('0 minutos → 00:00:00', () => expect(minutesToDuration(0)).toBe('00:00:00'))
|
||||
})
|
||||
|
||||
// ─── tituloFallback ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('tituloFallback', () => {
|
||||
it('sessao → Sessão', () => expect(tituloFallback('sessao')).toBe('Sessão'))
|
||||
it('bloqueio → Bloqueio', () => expect(tituloFallback('bloqueio')).toBe('Bloqueio'))
|
||||
it('pessoal → Pessoal', () => expect(tituloFallback('pessoal')).toBe('Pessoal'))
|
||||
it('clinica → Clínica', () => expect(tituloFallback('clinica')).toBe('Clínica'))
|
||||
it('desconhecido → Compromisso', () => expect(tituloFallback('outro')).toBe('Compromisso'))
|
||||
it('null → Compromisso', () => expect(tituloFallback(null)).toBe('Compromisso'))
|
||||
})
|
||||
|
||||
// ─── calcDefaultSlotDuration ──────────────────────────────────────────────────
|
||||
|
||||
describe('calcDefaultSlotDuration', () => {
|
||||
it('usa granularidade custom quando ativa', () => {
|
||||
const s = { usar_granularidade_custom: true, granularidade_min: 15 }
|
||||
expect(calcDefaultSlotDuration(s)).toBe('00:15:00')
|
||||
})
|
||||
|
||||
it('usa admin_slot_visual_minutos como fallback', () => {
|
||||
const s = { admin_slot_visual_minutos: 20 }
|
||||
expect(calcDefaultSlotDuration(s)).toBe('00:20:00')
|
||||
})
|
||||
|
||||
it('usa 30 min como padrão quando nenhuma configuração', () => {
|
||||
expect(calcDefaultSlotDuration({})).toBe('00:30:00')
|
||||
expect(calcDefaultSlotDuration(null)).toBe('00:30:00')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildWeeklyBreakBackgroundEvents ────────────────────────────────────────
|
||||
|
||||
describe('buildWeeklyBreakBackgroundEvents', () => {
|
||||
it('retorna vazio para input vazio', () => {
|
||||
const result = buildWeeklyBreakBackgroundEvents([], new Date('2026-03-01'), new Date('2026-03-08'))
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('gera eventos de background para pausa no dia correto', () => {
|
||||
const pausas = [{ weekday: 1, start: '12:00', end: '13:00', label: 'Almoço' }] // segunda
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 1), // dom
|
||||
new Date(2026, 2, 8), // dom
|
||||
)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].display).toBe('background')
|
||||
expect(result[0].start).toContain('2026-03-02') // segunda
|
||||
expect(result[0].extendedProps.label).toBe('Almoço')
|
||||
})
|
||||
|
||||
it('gera uma pausa por semana quando range cobre 2 semanas', () => {
|
||||
const pausas = [{ weekday: 1, start: '12:00', end: '13:00' }] // toda segunda
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 1), // dom 01/03
|
||||
new Date(2026, 2, 15), // dom 15/03
|
||||
)
|
||||
expect(result.length).toBe(2) // seg 02 e seg 09
|
||||
})
|
||||
|
||||
it('não gera para dias diferentes', () => {
|
||||
const pausas = [{ weekday: 5, start: '12:00', end: '13:00' }] // sexta
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 2), // seg
|
||||
new Date(2026, 2, 5), // qui
|
||||
)
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
@@ -1,124 +1,189 @@
|
||||
// src/features/agenda/services/agendaMappers.js
|
||||
//
|
||||
// Suporta dois tipos de linha:
|
||||
// 1. Evento real (agenda_eventos do banco) — is_occurrence = false/undefined
|
||||
// 2. Ocorrência virtual (gerada por useRecurrence) — is_occurrence = true
|
||||
//
|
||||
// Em ambos os casos o shape de saída para o FullCalendar é idêntico.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapAgendaEventosToCalendarEvents
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function mapAgendaEventosToCalendarEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
// 🔥 regra importante:
|
||||
// prioridade: owner_id
|
||||
// fallback: terapeuta_id
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
extendedProps: {
|
||||
// 🔥 ESSENCIAL PARA O MOSAICO
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
paciente_nome: r.patients?.nome_completo ?? null,
|
||||
paciente_avatar: r.patients?.avatar_url ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
// ✅ usados na clínica p/ mascarar/privacidade
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// ✅ compromisso determinístico
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// ✅ campos customizados
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
return (rows || []).map(_mapRow).filter(Boolean)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapAgendaEventosToClinicResourceEvents
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
const ev = _mapRow(r)
|
||||
if (!ev) return null
|
||||
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
return ev
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapper interno
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _mapRow (r) {
|
||||
if (!r) return null
|
||||
|
||||
const isOccurrence = !!r.is_occurrence
|
||||
const isRealSession = !isOccurrence
|
||||
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
// commitment / cores
|
||||
const commitment = r.determined_commitments ?? r.commitment ?? null
|
||||
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null
|
||||
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null
|
||||
const statusBg = _statusBgColor(r.status)
|
||||
const bgColor = statusBg ?? baseBg ?? undefined
|
||||
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined)
|
||||
|
||||
// título
|
||||
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? ''
|
||||
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo))
|
||||
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id)
|
||||
const title = `${icon}${titleBase}`
|
||||
|
||||
// recorrência — nova + fallback legada
|
||||
const recurrenceId = r.recurrence_id ?? null
|
||||
const originalDate = r.original_date ?? r.recurrence_date ?? null
|
||||
const exceptionType = r.exception_type ?? null
|
||||
|
||||
return {
|
||||
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
|
||||
title,
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
// identidade
|
||||
dbId: r.id ?? null,
|
||||
isOccurrence,
|
||||
isRealSession,
|
||||
|
||||
// owner
|
||||
owner_id: ownerId,
|
||||
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
|
||||
|
||||
// compromisso
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// paciente
|
||||
patient_id: r.patient_id ?? null,
|
||||
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
|
||||
paciente_nome: nomeP,
|
||||
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
|
||||
|
||||
// campos
|
||||
observacoes: r.observacoes ?? null,
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null,
|
||||
modalidade: r.modalidade ?? null,
|
||||
|
||||
// privacidade (clínica)
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// recorrência — NOVA arquitetura
|
||||
recurrence_id: recurrenceId,
|
||||
original_date: originalDate,
|
||||
exception_type: exceptionType,
|
||||
exception_id: r.exception_id ?? null,
|
||||
exception_reason: r.exception_reason ?? null,
|
||||
|
||||
// recorrência — fallback LEGADA (não quebra enquanto migra)
|
||||
serie_id: r.serie_id ?? recurrenceId ?? null,
|
||||
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
|
||||
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
|
||||
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
|
||||
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
|
||||
is_exception: r.is_exception ?? (exceptionType != null),
|
||||
|
||||
// financeiro
|
||||
price: r.price ?? null,
|
||||
|
||||
// timestamps
|
||||
inicio_em: r.inicio_em,
|
||||
fim_em: r.fim_em,
|
||||
tenant_id: r.tenant_id ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildNextSessions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
const nowMs = now.getTime()
|
||||
return (rows || [])
|
||||
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.filter(r => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.slice(0, 6)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
startISO: r.inicio_em,
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.paciente_id || null
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.patient_id || null
|
||||
}))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// calcDefaultSlotDuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function calcDefaultSlotDuration (settings) {
|
||||
const min =
|
||||
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
|
||||
settings?.admin_slot_visual_minutos ||
|
||||
30
|
||||
|
||||
return minutesToDuration(min)
|
||||
}
|
||||
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
const hh = String(h).padStart(2, '0')
|
||||
const mm = String(m).padStart(2, '0')
|
||||
return `${hh}:${mm}:00`
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildWeeklyBreakBackgroundEvents — código original preservado integralmente
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausas semanais (jsonb) -> background events do FullCalendar.
|
||||
* Leitura flexível:
|
||||
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
|
||||
*/
|
||||
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(pausas) || pausas.length === 0) return []
|
||||
|
||||
const out = []
|
||||
const out = []
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay() // 0..6
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay()
|
||||
|
||||
for (const p of pausas) {
|
||||
const wd = normalizeWeekday(p?.weekday)
|
||||
if (wd === null) continue
|
||||
if (wd !== dow) continue
|
||||
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana)
|
||||
if (wd === null || wd !== dow) continue
|
||||
|
||||
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
if (!start || !end) continue
|
||||
|
||||
out.push({
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
|
||||
@@ -129,48 +194,53 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
|
||||
return out
|
||||
}
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// minutesToDuration / tituloFallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
// 🔥 resourceId também precisa ser confiável
|
||||
resourceId: ownerId,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:00`
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// helpers de status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _statusBgColor (status) {
|
||||
const map = {
|
||||
realizado: '#6b7280',
|
||||
faltou: '#ef4444',
|
||||
cancelado: '#f97316',
|
||||
bloqueado: '#6b7280',
|
||||
remarcado: '#a855f7',
|
||||
}
|
||||
return map[status] ?? null
|
||||
}
|
||||
|
||||
function _statusIcon (status, isOccurrence, hasSerie) {
|
||||
if (status === 'realizado') return '✓ '
|
||||
if (status === 'faltou') return '✗ '
|
||||
if (status === 'cancelado') return '∅ '
|
||||
if (status === 'bloqueado') return '⊘ '
|
||||
if (status === 'remarcado') return '↺ '
|
||||
if (hasSerie || isOccurrence) return '↻ '
|
||||
return ''
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// helpers internos — originais preservados
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeId (v) {
|
||||
if (v === null || v === undefined) return null
|
||||
@@ -190,7 +260,7 @@ function normalizeWeekday (value) {
|
||||
function asTime (v) {
|
||||
if (!v || typeof v !== 'string') return null
|
||||
const s = v.trim()
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
|
||||
return null
|
||||
}
|
||||
@@ -203,7 +273,7 @@ function startOfDay (d) {
|
||||
|
||||
function combineDateTimeISO (date, timeHHMMSS) {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
|
||||
}
|
||||
@@ -36,6 +36,24 @@ export async function getMyAgendaSettings () {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regras semanais de jornada (agenda_regras_semanais):
|
||||
* retorna os dias ativos com hora_inicio/hora_fim por dia.
|
||||
*/
|
||||
export async function getMyWorkSchedule () {
|
||||
const uid = await getUid()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('owner_id', uid)
|
||||
.eq('ativo', true)
|
||||
.order('dia_semana')
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
|
||||
* Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
|
||||
@@ -59,27 +77,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
const rows = data || []
|
||||
|
||||
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
|
||||
// Fazemos um segundo fetch para esses casos e mesclamos.
|
||||
const orphanIds = [...new Set(
|
||||
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
|
||||
)]
|
||||
if (orphanIds.length) {
|
||||
const { data: pts } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', orphanIds)
|
||||
if (pts?.length) {
|
||||
const map = Object.fromEntries(pts.map(p => [p.id, p]))
|
||||
for (const r of rows) {
|
||||
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user