Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
@@ -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)
+198 -128
View File
@@ -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 || []
}
/**