/* * useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa. * -------------------------------------------------- * Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings, * eventos reais (useAgendaEvents), expande recorrências (useRecurrence), * resolve compromissos determinados (catálogo) e feriados, mantém o estado * do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam: * onDialogSave / onDialogDelete — CRUD com 7 cases (avulso, recorrente, * somente_este, este_e_seguintes, todos, * todos_sem_excecao) * onUpdateSeriesEvent — mudança de status numa ocorrência * onEditSeriesOccurrence — preview ao alternar editScope * persistMoveOrResize — drag/resize do FC com checagem de conflito * onSelectTime — click-drag pra criar evento novo * onEditEvento — popula dialogEventRow a partir do raw row * * Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH, * dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual * pra alimentar o AgendaEventDialog. * * Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles * no datesSet do FC (via provide/inject). O composable observa e refetcha. * * Os handlers exibem toasts (success/warn) — o composable assume que os * componentes consumidores já registraram `` e ``. */ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; import { useToast } from 'primevue/usetoast'; import { useConfirm } from 'primevue/useconfirm'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'; import { useRecurrence } from '@/features/agenda/composables/useRecurrence'; import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'; import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'; import { useFeriados } from '@/composables/useFeriados'; import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios'; // ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ────────────── const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' }); const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]); // Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint // `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra // evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se // `max_session_duration_min` for adicionado. const MAX_SESSION_MINUTES = 120; function isUuid(v) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || '')); } function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) { const s = String(t || '').trim().toLowerCase(); if (!s) return fallback; if (s.includes('sess')) return EVENTO_TIPO.SESSAO; if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO; return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback; } function deriveEventoTipoForNewEvent(payload) { const vis = String(payload?.visibility_scope || '').toLowerCase(); const title = String(payload?.titulo || '').toLowerCase(); if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO; return EVENTO_TIPO.SESSAO; } function deriveTituloDefaultByTipo(tipo) { return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão'; } function pickDbFields(obj) { const allowed = [ 'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id', 'tipo', 'status', 'titulo', 'observacoes', 'modalidade', 'inicio_em', 'fim_em', 'visibility_scope', 'mirror_of_event_id', 'mirror_source', 'determined_commitment_id', 'titulo_custom', 'extra_fields', 'recurrence_id', 'recurrence_date', 'price', 'insurance_plan_id', 'insurance_guide_number', 'insurance_value', 'insurance_plan_service_id' ]; const out = {}; for (const k of allowed) { if (obj[k] !== undefined) out[k] = obj[k]; } return out; } function _addMinutesToTime(timeStr, minutes) { const [h, m] = String(timeStr || '09:00').split(':').map(Number); const total = h * 60 + m + Number(minutes || 0); const hh = Math.floor(total / 60); const mm = total % 60; return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`; } // ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ───── function pickColor(tipo, status, isOccurrence) { const s = String(status || '').toLowerCase(); if (s === 'realizado' || s === 'realizada') return '#10b981'; if (s === 'faltou') return '#ef4444'; if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; const t = String(tipo || '').toLowerCase(); if (t === 'bloqueio') return '#64748b'; if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo } function isoToDecimalHour(iso) { if (!iso) return 0; const d = new Date(iso); return d.getHours() + d.getMinutes() / 60; } function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) { // r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand // (ocorrência virtual com is_occurrence=true e id "rec::uuid::date"). const isOccurrence = !!r.is_occurrence; const pacNome = r.paciente_nome ?? r.patient_name ?? r.patients?.nome_completo ?? ''; const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO); // Estado de pagamento — lido do _paymentStateMap (preenchido pelo // _reloadRange via bulk-query em financial_records). Default 'none' // pra ocorrências virtuais (sem id real ainda) e eventos sem record. // 'paid' | 'pending' | 'none'. // Fallback pra virtuais ou reais sem record: herda do contrato upfront // pago da série via _rulePaymentMap (chave = recurrence_id). let paymentState = (!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null; let paymentAmount = !isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null; if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) { paymentState = rulePaymentMap[r.recurrence_id].state; if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount; } if (!paymentState) paymentState = 'none'; return { id: r.id, tipo, status: r.status || (isOccurrence ? 'agendado' : ''), titulo: r.titulo || r.titulo_custom || '', patient_id: r.patient_id ?? r.paciente_id ?? null, pacienteNome: pacNome, modalidade: r.modalidade || '', descricao: r.observacoes || '', color: pickColor(tipo, r.status, isOccurrence), label: pacNome || r.titulo || r.titulo_custom || (tipo === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : '—'), inicio_em: r.inicio_em, fim_em: r.fim_em, startH: isoToDecimalHour(r.inicio_em), endH: isoToDecimalHour(r.fim_em), dateKey: String(r.inicio_em || '').slice(0, 10), is_occurrence: isOccurrence, recurrence_id: r.recurrence_id ?? null, recurrence_date: r.recurrence_date ?? r.original_date ?? null, paymentState, paymentAmount, // Info do contrato (saldo/upfront) — injetado quando a série tem // billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M". contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null, price: r.price != null ? Number(r.price) : null, // insurance_value: pra convênio, o valor cobrado mora aqui (não em // price). Popover e Resumo usam fallback `price ?? insurance_value` // pra mostrar o valor da cobrança independente do tipo. insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null, _raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow }; } // ─────────────────────────────────────────────────────────────────────────── // Composable principal // ─────────────────────────────────────────────────────────────────────────── // Símbolo de injeção pra MelissaAgenda recuperar o composable do MelissaLayout // sem prop drilling. Pattern: MelissaLayout chama `provide(MELISSA_AGENDA_KEY, m)` // e MelissaAgenda chama `inject(MELISSA_AGENDA_KEY)`. export const MELISSA_AGENDA_KEY = Symbol('melissaAgenda'); export function useMelissaAgenda() { const toast = useToast(); const confirm = useConfirm(); const tenantStore = useTenantStore(); // Refs do range visível — mutados pela MelissaAgenda no datesSet do FC. // Início default: semana ao redor de hoje (FC sobrescreve no mount). const viewStart = ref(new Date()); const viewEnd = ref(new Date()); { const hoje = new Date(); const dow = hoje.getDay(); const diff = dow === 0 ? -6 : 1 - dow; const segunda = new Date(hoje); segunda.setDate(hoje.getDate() + diff); segunda.setHours(0, 0, 0, 0); const domingoNext = new Date(segunda); domingoNext.setDate(segunda.getDate() + 7); viewStart.value = segunda; viewEnd.value = domingoNext; } const clinicTenantId = computed( () => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null ); // ── Settings + workRules ──────────────────────────────────── // cache: stale-while-revalidate via melissaCacheStore — abertura // subsequente da Agenda na mesma sessão usa cache instantâneo. const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true }); // _bootUid: pegado em paralelo no mount via supabase.auth.getUser(). // Sem isso, ownerId ficava null até loadSettings completar (~300ms), // bloqueando o primeiro fetch dos eventos. Como owner_id da agenda // é literalmente o uid do user logado, podemos resolver imediato. const _bootUid = ref(''); const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || ''); // ── Eventos reais (CRUD) ──────────────────────────────────── const { rows, loading: eventsLoading, error: eventsError, loadMyRange, create, update, remove } = useAgendaEvents(); // ── Recorrência ───────────────────────────────────────────── const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence(); const _occurrenceRows = ref([]); // ── Compromissos determinados (catálogo) ──────────────────── const { rows: determinedCommitments, load: loadDeterminedCommitments } = useDeterminedCommitments(clinicTenantId); // Adapta pro shape que o AgendaEventDialog espera (mesma lógica da // AgendaTerapeutaPage — prioridade pra "Sessão" primeiro) const commitmentOptions = computed(() => { const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []; const priority = new Map([ ['session', 0], ['class', 1], ['study', 2], ['reading', 3], ['supervision', 4], ['content_creation', 5] ]); return [...list] .filter((i) => i?.id && i?.active !== false) .sort((a, b) => { const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99; const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99; if (pa !== pb) return pa - pb; return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR'); }) .map((i) => ({ id: i.id, tenant_id: i.tenant_id ?? null, created_by: i.created_by ?? null, name: String(i.name || '').trim() || 'Sem nome', description: i.description || '', native_key: i.native_key || null, is_native: !!i.is_native, is_locked: !!i.is_locked, active: i.active !== false, bg_color: i.bg_color || null, text_color: i.text_color || null, fields: Array.isArray(i.determined_commitment_fields) ? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : [] })); }); // ── Feriados + commitment services ────────────────────────── // Instância única de useFeriados — antes MelissaAgenda.vue criava // sua própria também, fazendo dupla requisição de feriados municipais // toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do // composable injetado (M.feriadosAno, M.loadFeriadosBase, etc). const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase, ano: feriadosAno } = useFeriados({ cache: true }); const { saveRuleItems, propagateToSerie } = useCommitmentServices(); // Bloqueios — renderizados como background events cinza no FullCalendar // (entidade própria, distinta de agenda_eventos). Carregados via range // visível + recorrentes; reativo a viewStart/viewEnd via _reloadRange. const { bloqueios, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios(); const bloqueioFcEvents = computed(() => { const s = viewStart.value; const e = viewEnd.value; if (!s || !e) return []; return buildBloqueioEvents(s, e); }); // Detecta se um range [start, end] cai dentro de algum bloqueio carregado. // Cobre bloqueios dia-inteiro (sem hora), com janela horária e recorrentes // semanais (via dia_semana). Usado pra avisar o terapeuta quando ele tenta // criar sessão em cima de bloqueio próprio — não impede, só sinaliza. function bloqueioCobrindo(start, end) { const arr = bloqueios?.value || []; if (!arr.length || !start) return null; const dStart = start instanceof Date ? start : new Date(start); const dEnd = end instanceof Date ? end : new Date(end || start); const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`; const dow = dStart.getDay(); const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes(); const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes(); const parseHM = (s) => { if (!s) return null; const [h, m] = String(s).split(':').map(Number); return Number.isFinite(h) ? h * 60 + (m || 0) : null; }; for (const b of arr) { if (!b) continue; if (b.recorrente && b.dia_semana != null) { if (Number(b.dia_semana) !== dow) continue; } else { const di = b.data_inicio; const df = b.data_fim || b.data_inicio; if (!di) continue; if (isoDay < di || isoDay > df) continue; } const bhi = parseHM(b.hora_inicio); const bhf = parseHM(b.hora_fim); if (bhi == null || bhf == null) return b; if (hhmmStart < bhf && hhmmEnd > bhi) return b; } return null; } // ── Linhas combinadas (real + virtual) ────────────────────── const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]); // Map de estado de pagamento por agenda_evento_id, preenchido pelo // _reloadRange via bulk-query em financial_records. Permite renderizar // badge "$ pendente" no FC e linha "A receber" no popover sem ter que // queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'. const _paymentStateMap = ref({}); // Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo // popover e Resumo do dialog quando paymentState='paid' pra mostrar o // valor REAL pago (vs evento.price que pode ter sido editado depois). const _paymentAmountMap = ref({}); // Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não // têm id real e portanto não entram em _paymentStateMap), normalize lê // daqui o estado herdado do contrato upfront pago da série. const _rulePaymentMap = ref({}); // Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice} // — info do billing_contract da série pra exibir no popover. const _ruleContractMap = ref({}); // ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ── const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value))); // ── Eventos do FC original (se precisar — AgendaEventDialog quer // `allEvents` no shape FC pra checar conflitos) ───────── const allEventsForDialog = computed(() => allRows.value.map((r) => ({ id: r.id, start: r.inicio_em, end: r.fim_em, extendedProps: { ...r } })) ); // ── Dialog state ──────────────────────────────────────────── const dialogOpen = ref(false); const dialogEventRow = ref(null); const dialogStartISO = ref(''); const dialogEndISO = ref(''); // Aviso quando o slot escolhido cai em cima de um bloqueio próprio. // Renderizado pelo AgendaEventDialog no topo do step 1 (Message warn). // null = sem aviso; { titulo } = aviso ativo. const dialogBlockOverlap = ref(null); // Segundo dialog (empilhado por cima do principal) — usado pra editar // uma OCORRÊNCIA específica de uma série. Acionado pelo botão "Editar" // de cada pill na lista "Recorrências Aplicadas" do AgendaEventDialog. // Adicionado 2026-05-11 — espelho pra Rail/Clínica está pendente. const occDialogOpen = ref(false); const occDialogEventRow = ref(null); const occDialogStartISO = ref(''); const occDialogEndISO = ref(''); // serieRefreshTick: bump quando o 2º dialog (ocorrencia) faz save com // sucesso. AgendaEventDialog do dialog PAI observa essa prop e re-roda // loadSerieEvents pra atualizar a lista de pills "Recorrencias Aplicadas". // Sem isso, pills ficavam com status stale ate fechar/reabrir o pai. 2026-05-12. const serieRefreshTick = ref(0); // Status change confirm dialog (Fase 5, 2026-05-14) — aparece quando // user muda status pra realizado/faltou/cancelado e há decisão a tomar // (regra de exceção, pacote saldo, pending record). const statusDialogOpen = ref(false); const statusDialogProps = ref({}); // Resolver guardado fora do ref pra não vazar pro template via reactivity. let _statusDialogResolve = null; function _openStatusDialog(propsObj) { return new Promise((resolve) => { statusDialogProps.value = propsObj; statusDialogOpen.value = true; _statusDialogResolve = resolve; }); } function onStatusDialogConfirm(decision) { if (_statusDialogResolve) _statusDialogResolve(decision); _statusDialogResolve = null; } function onStatusDialogCancel() { // PrimeVue Dialog dispara update:visible(false) ao fechar via X/Esc; // tratar isso aqui ainda — resolve com null pra cancelar. if (_statusDialogResolve) _statusDialogResolve(null); _statusDialogResolve = null; } // Bloqueio dialog (modo: 'horario' | 'periodo' | 'dia' | 'feriados') const bloqueioDialogOpen = ref(false); const bloqueioMode = ref('horario'); function openBloqueioDialog(mode) { if (!ownerId.value) { toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }); return; } bloqueioMode.value = mode || 'horario'; bloqueioDialogOpen.value = true; } // ── Refetch (com merge real+virtual) ──────────────────────── async function _reloadRange() { const s = viewStart.value; const e = viewEnd.value; if (!s || !e) return; // Espera ownerId E tenant — qualquer um faltando significa boot // ainda em curso (auth/tenantStore/settings async). Watcher one-shot // re-dispara assim que o último ficar disponível, sem polling. if (!ownerId.value || !clinicTenantId.value) { const unwatch = watch( () => [ownerId.value, clinicTenantId.value], ([uid, tid]) => { if (!uid || !tid) return; unwatch(); _reloadRange(); } ); return; } const start = new Date(s); const end = new Date(e); const tid = clinicTenantId.value; // Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza // assim que esse await resolve (o user já vê as sessões agendadas). await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value); // Etapa 2: ocorrências virtuais (regras de recorrência expandidas). // Continuamos awaitando porque saveRule/cancel dependem do estado // final estar pronto pra UI consistente, mas a janela visual onde // o usuário vê só eventos reais é a metade do tempo de antes. const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid); _occurrenceRows.value = merged.filter((r) => r.is_occurrence); // Etapa 3: bloqueios (background events). Não bloqueia render do // calendário — load assíncrono em paralelo. bloqueioFcEvents é computed // e re-renderiza quando bloqueios.value atualizar. loadBloqueios(ownerId.value, start, end); // Etapa 4: estado de pagamento — bulk-query 1x pra todos os eventos // reais visíveis. Annotação 'paid' | 'pending' fica no _paymentStateMap; // o computed `eventos` re-corre normalizeForMelissa quando o map muda. // Não bloqueia render — eventos aparecem sem badge até este await // resolver (default 'none'). const realIds = (rows.value || []).map((r) => r.id).filter(Boolean); // Inicializa maps SEMPRE (não condicional a realIds.length>0) — em // semanas onde só há virtuais (sem eventos reais), ainda precisamos // rodar a propagação cross-week pra ruleMap. const map = {}; const amountMap = {}; const ruleMap = {}; const ruleContractMap = {}; // Filtra cancelados: cobrança cancelada não deve manter // paymentState='pending' (badge $ residual). Tratamos cancelled // como "sem cobrança ativa" → cai pro default 'none'. if (realIds.length) { const { data: recs } = await supabase .from('financial_records') .select('agenda_evento_id, paid_at, status, amount, final_amount') .in('agenda_evento_id', realIds) .neq('status', 'cancelled'); for (const id of realIds) map[id] = 'none'; for (const rec of recs || []) { const eid = rec.agenda_evento_id; if (!eid) continue; if (rec.paid_at) { // Paid override: se QUALQUER record dessa sessão está paid, // consideramos cobrança honrada. Filhos (multas/taxas) que // venham pendentes não revertem esse estado pro badge. map[eid] = 'paid'; amountMap[eid] = rec.final_amount ?? rec.amount ?? null; } else if (map[eid] !== 'paid') { map[eid] = 'pending'; if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null; } } } // Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais // na view), pra que virtuais isoladas em semanas futuras herdem o // estado do contrato cross-week. Antes estava dentro de // `if (realIds.length)` e falhava em semanas só com virtuais. // // Quando a 1ª materializada // de uma série tem record (paid OU pending), TODAS as ocorrências // do mesmo recurrence_id devem herdar o mesmo estado (cobertas // pelo contrato — cobrança única do pacote inteiro). // Funciona pra real rows; virtuais ficam por loadOccFinancialRecord // no occurrenceMode (não passam por este bulk). // Estratégia: pega rules dos eventos com record OU dos eventos // visíveis que têm recurrence_id (cross-week — record do pacote // pode estar em outra semana), busca contratos upfront ativos // pros pacientes, propaga estado pra siblings no view atual. try { // 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem // a uma série. Antes só usávamos eventos COM record paid/pending, // o que falhava cross-week (record do pacote pode estar em // outra semana). Agora propagação cobre Ana 2/3/4 mesmo // quando o record está só no Ana 1. const ruleIdsInView = new Set(); for (const r of rows.value || []) { if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id); } // Virtuais expandidas (que vêm depois) também trazem rules: for (const r of _occurrenceRows.value || []) { if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id); } if (ruleIdsInView.size) { // 2) Acha patient_id direto das rules (fonte autoritativa, // funciona até quando rule não tem nenhum evento // materializado — caso pacote saldo recém-criado). const { data: rulesData } = await supabase .from('recurrence_rules') .select('id, patient_id') .in('id', [...ruleIdsInView]); const rulePatientFromRule = new Map(); for (const r of rulesData || []) { if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id); } // 3) Acha eventos (em qualquer semana) das rules em view + // seus records paid/pending pra detectar estado. const { data: allRuleEvents } = await supabase .from('agenda_eventos') .select('id, recurrence_id, patient_id') .in('recurrence_id', [...ruleIdsInView]); const ruleEventIds = (allRuleEvents || []).map((e) => e.id); let ruleRecords = []; if (ruleEventIds.length) { const { data: rr } = await supabase .from('financial_records') .select('agenda_evento_id, paid_at, status') .in('agenda_evento_id', ruleEventIds) .neq('status', 'cancelled'); ruleRecords = rr || []; } // Mapeia: rule_id → state (paid > pending > none) + patient const evIdToRule = new Map(); const evIdToPatient = new Map(); for (const e of allRuleEvents || []) { evIdToRule.set(e.id, e.recurrence_id); evIdToPatient.set(e.id, e.patient_id); } const ruleToPatient = new Map(); const ruleToState = new Map(); for (const rec of ruleRecords) { const rid = evIdToRule.get(rec.agenda_evento_id); const pid = evIdToPatient.get(rec.agenda_evento_id); if (!rid || !pid) continue; ruleToPatient.set(rid, pid); const newState = rec.paid_at ? 'paid' : 'pending'; const existing = ruleToState.get(rid); if (existing !== 'paid') ruleToState.set(rid, newState); } // Busca contratos ativos pra TODOS pacientes envolvidos // (saldo OU upfront — ambos exibem info no popover). // Query única com todos campos necessários. Usa // rulePatientFromRule (fonte autoritativa) pra cobrir // saldo sem records (não passa por ruleToPatient). const allPatientIds = [...new Set(rulePatientFromRule.values())]; let activePackages = []; if (allPatientIds.length) { const { data: contracts } = await supabase .from('billing_contracts') .select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price') .in('patient_id', allPatientIds); activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active'); } // Index por patient_id pra lookup rápido const contractByPatient = new Map(); for (const c of activePackages) contractByPatient.set(c.patient_id, c); // Popula ruleContractMap pra TODAS as rules em view com // contrato ativo (saldo + upfront, com OU sem records). for (const [rid, pid] of rulePatientFromRule.entries()) { const c = contractByPatient.get(pid); if (c) { ruleContractMap[rid] = { id: c.id, style: c.charging_style || 'upfront', totalSessions: c.total_sessions || 0, sessionsUsed: c.sessions_used || 0, packagePrice: Number(c.package_price || 0) }; } } if (ruleToPatient.size) { // NULL charging_style → assume upfront (default histórico // antes da migration 20260514000003). Pra dados antigos // sem a coluna preenchida, evita virtuais ficarem sem // propagação. 'saldo' explícito mantém siblings 'none'. const upfrontPatients = new Set( activePackages .filter((c) => c.charging_style === 'upfront' || c.charging_style == null) .map((c) => c.patient_id) ); const ruleIdsToPropagate = [...ruleToPatient.entries()] .filter(([, pid]) => upfrontPatients.has(pid)) .map(([rid]) => rid); if (ruleIdsToPropagate.length) { const { data: siblings } = await supabase .from('agenda_eventos') .select('id, recurrence_id') .in('recurrence_id', ruleIdsToPropagate); // Reusa contractByPatient da query unificada acima // (antes havia uma 2ª query redundante pro mesmo dado). const contractPriceByPatient = new Map(); for (const c of activePackages) { if (c.charging_style === 'upfront' || c.charging_style == null) { contractPriceByPatient.set(c.patient_id, c.package_price); } } for (const s of siblings || []) { if (map[s.id] !== undefined) { const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending'; // Não rebaixa: se sibling já está paid // (record próprio paid), mantém. if (map[s.id] !== 'paid') map[s.id] = stateToPropagate; const pid = ruleToPatient.get(s.recurrence_id); const price = contractPriceByPatient.get(pid); if (price != null) amountMap[s.id] = price; } } // Populate _rulePaymentMap pra que VIRTUAIS (ainda // não materializadas) também herdem o estado ao // passar pelo normalize. for (const rid of ruleIdsToPropagate) { const pid = ruleToPatient.get(rid); const price = contractPriceByPatient.get(pid); const state = ruleToState.get(rid) || 'pending'; ruleMap[rid] = { state, amount: price ?? null }; } } } } } catch (e) { console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message); } _paymentStateMap.value = map; _paymentAmountMap.value = amountMap; _rulePaymentMap.value = ruleMap; _ruleContractMap.value = ruleContractMap; } async function refetch() { await _reloadRange(); } // ── Inicialização ─────────────────────────────────────────── // Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo // tempo. Antes era serial (loadSettings precisava terminar pra ownerId // ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms // de waterfall antes da primeira query de eventos sair. onMounted(() => { // 1) Resolve o uid o quanto antes — destrava _reloadRange. // getSession() lê do storage local (fast path, <10ms); // getUser() faria round-trip pro auth server. Fallback pro // getUser só se a sessão ainda não estiver no storage. supabase.auth.getSession() .then(({ data }) => { const uid = data?.session?.user?.id; if (uid) { _bootUid.value = uid; } else { // Cold start sem sessão hidratada — fallback pro round-trip. return supabase.auth.getUser().then(({ data: u }) => { if (u?.user?.id) _bootUid.value = u.user.id; }); } }) .catch(() => { /* noop — settings ainda pode resolver */ }); // 2) Garante que o tenant está hidratado (idempotente — se já // estiver carregado, retorna imediato). if (typeof tenantStore.ensureLoaded === 'function') { tenantStore.ensureLoaded().catch(() => {}); } // 3) Settings em paralelo (não bloqueia mais nada) loadSettings(); }); // Refetch settings + workRules quando o user salva jornada/ritmo/online // em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline // do resumo continuaria mostrando o range antigo até reload da página. function _onSettingsSaved() { loadSettings(); } onMounted(() => { window.addEventListener('agenda:settings-saved', _onSettingsSaved); }); onBeforeUnmount(() => { window.removeEventListener('agenda:settings-saved', _onSettingsSaved); }); // Commitments + feriados dependem do tenant. Em refresh "frio", o // tenantStore ainda não terminou de hidratar quando o composable // monta, e clinicTenantId fica null. loadDeterminedCommitments faz // bail-out silencioso quando tenantId é vazio (rows = [], sem retry) // — daí o "às vezes" do bug onde commitmentOptions chegava vazio no // AgendaEventDialog. Watch com immediate: true dispara já se o tenant // estiver pronto, ou no momento exato em que ele aparecer. watch( clinicTenantId, async (tid) => { if (!tid) return; await loadDeterminedCommitments(); await loadFeriadosBase(tid); }, { immediate: true } ); // Reload quando o range visível muda. _reloadRange já tem guard // interno pra esperar uid+tenant (one-shot watcher) — sem necessidade // de outro watch global em ownerId, que disparava _reloadRange duplicado. watch([viewStart, viewEnd], _reloadRange); // ────────────────────────────────────────────────────────── // Handlers — populados na Stage 2 // ────────────────────────────────────────────────────────── const _stageTwo = {}; // placeholder pra próxima passada Object.assign(_stageTwo, _buildHandlers({ toast, confirm, supabase, ownerId, clinicTenantId, settings, rows, allRows, eventsError, create, update, remove, loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException, saveRuleItems, propagateToSerie, dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO, occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO, serieRefreshTick, // Fase 5: dialog de status change vive no escopo do composable // (refs ficam aqui pra expor pro template); _buildHandlers precisa // do opener pra abrir e aguardar decisão antes de aplicar. _openStatusDialog, _reloadRange, bloqueioCobrindo, dialogBlockOverlap })); return { // View range (mutado pela MelissaAgenda via FC.datesSet) viewStart, viewEnd, // Eventos pro FC eventos, rawRows: allRows, loading: eventsLoading, refetch, // Dialog state dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO, dialogBlockOverlap, // 2º dialog (edit ocorrência da série, empilhado por cima) occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO, serieRefreshTick, // Bloqueio dialog bloqueioDialogOpen, bloqueioMode, openBloqueioDialog, // Status change confirm dialog (Fase 5) statusDialogOpen, statusDialogProps, onStatusDialogConfirm, onStatusDialogCancel, // Settings + commitments + feriados (props pro AgendaEventDialog) settings, workRules, ownerId, clinicTenantId, commitmentOptions, feriados, feriadoFcEvents, feriadosAno, loadFeriadosBase, allEventsForDialog, // Bloqueios (background events cinza no FC) bloqueios, bloqueioFcEvents, // Handlers ..._stageTwo }; } // ─────────────────────────────────────────────────────────────────────────── // Handlers — extraídos numa função à parte só por organização (mantém o // composable principal legível). Stage 2 preenche as funções abaixo. // ─────────────────────────────────────────────────────────────────────────── function _buildHandlers(deps) { // Só desempacota o que os handlers desta função usam diretamente — // _buildOnDialogSave/_buildOnDialogDelete recebem `deps` completo. const { toast, confirm, ownerId, clinicTenantId, settings, allRows, eventsError, create, update, dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO, occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO, _openStatusDialog, bloqueioCobrindo, dialogBlockOverlap } = deps; // Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo // "16h" / "15:15h" / "27/04"). function _fmtH(d) { if (!d) return '?'; const h = d.getHours(); const m = d.getMinutes(); return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`; } function _fmtD(d) { if (!d) return ''; return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; } // Escape HTML — usado quando a mensagem do confirm tem partes // controladas pelo usuário (nome do paciente) e v-html no slot. function _esc(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ── // Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado // pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick). function onEditEvento(rawRow) { if (!rawRow) return; dialogEventRow.value = { id: rawRow.is_occurrence ? null : rawRow.id || null, owner_id: rawRow.owner_id ?? ownerId.value, terapeuta_id: rawRow.terapeuta_id ?? null, paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null, paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null, paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null, paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null, tipo: rawRow.tipo || 'sessao', status: rawRow.status, titulo: rawRow.titulo, observacoes: rawRow.observacoes ?? null, visibility_scope: rawRow.visibility_scope ?? 'public', inicio_em: rawRow.inicio_em, fim_em: rawRow.fim_em, modalidade: rawRow.modalidade ?? null, determined_commitment_id: rawRow.determined_commitment_id ?? null, titulo_custom: rawRow.titulo_custom ?? null, extra_fields: rawRow.extra_fields ?? null, price: rawRow.price != null ? Number(rawRow.price) : null, insurance_plan_id: rawRow.insurance_plan_id ?? null, insurance_guide_number: rawRow.insurance_guide_number ?? null, insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null, insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null, // recorrência recurrence_id: rawRow.recurrence_id ?? null, original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null, is_occurrence: !!rawRow.is_occurrence, exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null, // legado serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null, serie_dia_semana: rawRow.serie_dia_semana ?? null, serie_hora: rawRow.serie_hora ?? null }; dialogStartISO.value = ''; dialogEndISO.value = ''; dialogBlockOverlap.value = null; dialogOpen.value = true; } // ── onCreateEvento — botão "+ Agendar" sem seleção no FC ── // Espelha AgendaTerapeutaPage.onCreateFromButton:1423 — usa o dia // visualizado + hora atual como defaults razoáveis. function onCreateEvento() { if (!ownerId.value) { toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }); return; } const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const base = new Date(); // Arredonda pra próximo slot de 15min — UX mais limpa que minuto solto. base.setSeconds(0, 0); const remainder = base.getMinutes() % 15; if (remainder !== 0) { base.setMinutes(base.getMinutes() + (15 - remainder)); } dialogEventRow.value = { owner_id: ownerId.value, terapeuta_id: null, paciente_id: null, tipo: EVENTO_TIPO.SESSAO, status: 'agendado', titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO), observacoes: null, visibility_scope: 'public', determined_commitment_id: null }; dialogStartISO.value = base.toISOString(); dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString(); dialogBlockOverlap.value = null; dialogOpen.value = true; } // ── onCreateEventoForPatient — abre o AgendaEventDialog com paciente // pre-selecionado. Usado pelo MelissaPaciente quando o user clica // "Agendar" na sidebar Acoes Rapidas. Mesma logica de onCreateEvento // (defaults razoaveis: hoje proximo slot 15min, duracao default), so // que injeta paciente_id no dialogEventRow. function onCreateEventoForPatient(patientId) { if (!ownerId.value) { toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }); return; } const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const base = new Date(); base.setSeconds(0, 0); const remainder = base.getMinutes() % 15; if (remainder !== 0) { base.setMinutes(base.getMinutes() + (15 - remainder)); } dialogEventRow.value = { owner_id: ownerId.value, terapeuta_id: null, paciente_id: patientId ? String(patientId) : null, tipo: EVENTO_TIPO.SESSAO, status: 'agendado', titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO), observacoes: null, visibility_scope: 'public', determined_commitment_id: null }; dialogStartISO.value = base.toISOString(); dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString(); dialogBlockOverlap.value = null; dialogOpen.value = true; } // ── onSelectTime — click-drag no FC pra criar evento novo ── // Dinâmica de duração: // click sem drag → settings.session_duration_min (default 50) // drag ≤ default → default (mínimo da configuração) // drag entre default e MAX_SESSION_MINUTES → respeita o drag (snap em // 15min já feito pelo FC via snapDuration) // drag > MAX → capa em MAX (regra de negócio Melissa: 2h // é o slot máximo permitido) function onSelectTime(selection) { const defaultDur = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start); const rawEnd = selection.end ? (selection.end instanceof Date ? selection.end : new Date(selection.end)) : null; const dragMin = rawEnd ? Math.round((rawEnd.getTime() - rawStart.getTime()) / 60000) : 0; const durMin = dragMin <= defaultDur ? defaultDur : dragMin <= MAX_SESSION_MINUTES ? dragMin : MAX_SESSION_MINUTES; const startISO = rawStart.toISOString(); const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString(); // Aviso de bloqueio — não veta criação (só agendador público veta), // mas sinaliza pro terapeuta que tá agendando em cima de bloqueio. // Renderizado como Message no topo do step 1 do AgendaEventDialog // (toast antigo ficava atrás do overlay do dialog). const bloqHit = bloqueioCobrindo(rawStart, new Date(rawStart.getTime() + durMin * 60000)); dialogBlockOverlap.value = bloqHit ? { titulo: bloqHit.titulo || 'Bloqueio' } : null; dialogEventRow.value = { owner_id: ownerId.value, terapeuta_id: null, paciente_id: null, tipo: EVENTO_TIPO.SESSAO, status: 'agendado', titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO), observacoes: null, visibility_scope: 'public', determined_commitment_id: null }; dialogStartISO.value = startISO; dialogEndISO.value = endISO; dialogOpen.value = true; } // ── persistMoveOrResize — drag/resize do FC ── // Fluxo: // 1. Bail se for ocorrência virtual (sem id real) // 2. Conflict-check ANTES de confirmar (revert+toast imediato evita // sequência ruim de "confirma? > ah não, conflito") // 3. Confirm dialog descrevendo a alteração ("trocando Leonardo de // 16h para 15:15h, confirma?") // 4. Se aceitar, UPDATE + toast success. Se recusar, revert. async function persistMoveOrResize(info, actionLabel) { try { const ev = info?.event; if (!ev) return; const id = ev.id; if (!id || !isUuid(id)) { info?.revert?.(); toast.add({ severity: 'info', summary: 'Sessão recorrente', detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".', life: 4500 }); return; } const startISO = ev.start ? ev.start.toISOString() : null; const endISO = ev.end ? ev.end.toISOString() : null; if (!startISO || !endISO) throw new Error('Compromisso sem start/end.'); const start = new Date(startISO); const end = new Date(endISO); const breakMin = settings.value?.session_break_min || 0; const conflict = allRows.value.find((r) => { if (!r.inicio_em || r.id === id) return false; const rS = new Date(r.inicio_em); const rE = new Date(r.fim_em || r.inicio_em); return start < new Date(rE.getTime() + breakMin * 60000) && end > rS; }); if (conflict) { info?.revert?.(); toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 }); return; } // Confirm: monta mensagem descrevendo o que mudou. Usa HTML // (renderizado via v-html no slot #message do ConfirmDialog em // MelissaLayout) pra destacar datas/horas em . Subject // é escapado pra evitar XSS via nome de paciente. const oldStart = info.oldEvent?.start || null; const oldEnd = info.oldEvent?.end || null; const ext = ev.extendedProps || {}; const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento'; const subjectEsc = _esc(subject); const startMoved = oldStart && start.getTime() !== oldStart.getTime(); const endMoved = oldEnd && end.getTime() !== oldEnd.getTime(); let message; if (startMoved) { const sameDay = oldStart.toDateString() === start.toDateString(); if (sameDay) { // Mudou só hora (mesmo dia) message = `Está trocando o horário de ${subjectEsc} de ` + `${_fmtH(oldStart)} para ` + `${_fmtH(start)}. Confirma?`; } else { // Mudou de dia (com ou sem mudança de hora) — destaca // data E hora em ambos os lados message = `Está movendo ${subjectEsc} de ` + `${_fmtD(oldStart)} ${_fmtH(oldStart)} para ` + `${_fmtD(start)} ${_fmtH(start)}. Confirma?`; } } else if (endMoved) { // Só resize — mostra duração antiga → nova const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000); const newDur = Math.round((end.getTime() - start.getTime()) / 60000); message = `Está alterando a duração de ${subjectEsc} de ` + `${oldDur}min para ` + `${newDur}min. Confirma?`; } else { message = `Confirmar alteração de ${subjectEsc}?`; } const accepted = await new Promise((resolve) => { confirm.require({ header: actionLabel, message, icon: 'pi pi-clock', acceptLabel: 'Confirmar', rejectLabel: 'Cancelar', acceptClass: 'p-button-primary', rejectClass: 'p-button-text', accept: () => resolve(true), reject: () => resolve(false), onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar }); }); if (!accepted) { info?.revert?.(); return; } await update(id, { inicio_em: startISO, fim_em: endISO }); toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 }); } catch (e) { info?.revert?.(); toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 }); } } // ── onEditSeriesOccurrence — abre 2º dialog empilhado pra editar // UMA ocorrência específica da série (preservando o dialog principal // aberto por baixo). Substituiu o pattern antigo de mutar // dialogEventRow in-place (que silenciosamente trocava os dados do // dialog atual e confundia o usuário). Adicionado 2026-05-11. function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) { const current = dialogEventRow.value || {}; occDialogEventRow.value = { ...current, id: id || null, inicio_em, fim_em, recurrence_date, _is_virtual: is_virtual, // is_occurrence=true sinaliza pro AgendaEventDialog que essa row // ja é uma ocorrencia de serie — composer.isEdit retorna true e o // lifecycle init pula direto pro step 2 (sem mostrar seletor de // tipo de compromisso). Sem isso, ocorrencia virtual (id=null) // abria step 1 "escolha o tipo" indevidamente. 2026-05-12. is_occurrence: true }; occDialogStartISO.value = inicio_em || ''; occDialogEndISO.value = fim_em || ''; occDialogOpen.value = true; } // ── onUpdateSeriesEvent — mudança de status numa ocorrência ── // // `row` opcional: row completa quando o chamador NÃO abriu o dialog antes // (MelissaEventoPanel clica direto no evento → não há dialogEventRow ainda). // Sem isso, recurrence_id/patient_id caem pra null e criavam row órfã. // ── Status change (Fase 5, 2026-05-14) ───────────────────────── // Pra realizado/faltou/cancelado: abre confirm dialog com defaults // vindos de financial_exceptions + billing_contracts, deixa terapeuta // override caso a caso. Pros outros status (agendado/confirmado/ // remarcado): aplicação direta (path legacy). async function onUpdateSeriesEvent(arg) { const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {}; try { const needsDialog = ['realizado', 'faltou', 'cancelado'].includes(status); if (!needsDialog) { await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }); return; } const row = callerRow || dialogEventRow.value || {}; const ctx = await _loadStatusChangeContext({ row, eventoId: id, status }); // Se nada pra perguntar (ex: status sem regra + sem contrato + sem record pendente), aplica direto if (!_needsConfirmDialog(status, ctx)) { await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }); return; } // Abre dialog e espera decisão const decision = await _openStatusDialog({ evento: row, novoStatus: status, regraExcecao: ctx.regraExcecao, billingContract: ctx.billingContract, billingContractStyle: ctx.billingContract?.charging_style ?? null, pendingRecord: ctx.pendingRecord, sessionPrice: Number(row.price ?? 0) }); if (!decision) { // User cancelou — reverte status no form do AgendaEventDialog // (se o trigger veio de lá). Callback opcional. if (typeof onReject === 'function') onReject(); return; } // 1) Materializa ocorrência virtual (ou só update status) — usa o id real const realId = await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }); // 2) Aplica decisões do dialog await _applyStatusDecisions({ eventoId: realId, row, novoStatus: status, ctx, decision }); } catch (e) { toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 }); } } // Aplica apenas o UPDATE de status (materializando se virtual). Path legacy // pra status que não precisam de dialog. Retorna o id real do evento // (pra Fase 5 saber qual record/saldo manipular depois). async function _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }) { if (id) { await update(id, { status }); return id; } if (!is_virtual || !inicio_em) return null; const row = callerRow || dialogEventRow.value || {}; const rid = row.recurrence_id ?? row.serie_id ?? dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null; const rDate = recurrence_date || inicio_em?.slice(0, 10); if (!rid) { toast.add({ severity: 'warn', summary: 'Erro', detail: 'Não foi possível identificar a regra de recorrência desta ocorrência.', life: 4000 }); return null; } const { data: existing, error: exErr } = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', rid) .eq('recurrence_date', rDate) .maybeSingle(); if (exErr) throw exErr; if (existing?.id) { await update(existing.id, { status }); return existing.id; } else { const created = await create({ owner_id: ownerId.value, tenant_id: clinicTenantId.value, recurrence_id: rid, recurrence_date: rDate, tipo: 'sessao', status, inicio_em, fim_em, visibility_scope: 'public', titulo: row.titulo || 'Sessão', patient_id: row.patient_id || row.paciente_id || null, determined_commitment_id: row.determined_commitment_id || null, modalidade: row.modalidade || 'presencial', price: row.price ?? null }); return created?.id ?? null; } } // Carrega contexto pra decidir se mostra dialog e quais blocos renderizar. async function _loadStatusChangeContext({ row, eventoId, status }) { const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null }; // 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation) const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' }; const excType = exceptionTypeMap[status]; if (excType && clinicTenantId.value) { try { const { data } = await supabase .from('financial_exceptions') .select('*') .eq('tenant_id', clinicTenantId.value) .eq('exception_type', excType) .or(`owner_id.eq.${ownerId.value},owner_id.is.null`) .order('owner_id', { ascending: false, nullsLast: true }) .limit(1) .maybeSingle(); ctx.regraExcecao = data ?? null; } catch (e) { console.warn('[Fase5] erro carregando regra de exceção:', e?.message); } } // 2) Billing contract — tenta 3 caminhos: // (a) row.billing_contract_id direto (sessão real materializada) // (b) eventoId real → query agenda_eventos.billing_contract_id // (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente const patientId = row.patient_id ?? row.paciente_id ?? null; const contractId = row.billing_contract_id ?? null; if (contractId) { try { const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle(); ctx.billingContract = data ?? null; } catch (e) { console.warn('[Fase5] erro contract via id direto:', e?.message); } } if (!ctx.billingContract && eventoId) { // Sessão real materializada — pode ter billing_contract_id no DB mesmo // que a row passada não tenha (caso de virtual recém-materializada). try { const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle(); if (ev?.billing_contract_id) { const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle(); ctx.billingContract = c ?? null; } } catch (e) { console.warn('[Fase5] erro contract via agenda_evento:', e?.message); } } if (!ctx.billingContract && patientId && clinicTenantId.value) { // Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo // do paciente. MVP assume 1 contrato active por paciente; pega o // mais recente caso haja mais de um. try { const { data: c } = await supabase .from('billing_contracts') .select('*') .eq('tenant_id', clinicTenantId.value) .eq('patient_id', patientId) .eq('status', 'active') .eq('type', 'package') .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); ctx.billingContract = c ?? null; } catch (e) { console.warn('[Fase5] erro contract via patient_id:', e?.message); } } // 3) Pending record (se evento já existe e tem cobrança pendente) if (eventoId) { try { const { data } = await supabase .from('financial_records') .select('*') .eq('agenda_evento_id', eventoId) .in('status', ['pending', 'overdue']) .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); ctx.pendingRecord = data ?? null; } catch (e) { console.warn('[Fase5] erro pending record:', e?.message); } } return ctx; } // Precisa dialog? Sim se há regra de exceção com charge_mode != 'none' // OU pacote saldo OU pacote upfront OU pending record (realizado). function _needsConfirmDialog(status, ctx) { const isFaltouOrCancel = status === 'faltou' || status === 'cancelado'; const isRealizado = status === 'realizado'; const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none'; const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo'; const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront'; const hasPending = !!ctx.pendingRecord; if (isFaltouOrCancel) { // Mostra se há regra ou se é pacote saldo (pra perguntar consume) return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront; } if (isRealizado) { // Mostra se há pending (avulsa) ou pacote saldo (cobrança nova) return hasPending || isPacoteSaldo; } return false; } // Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote). async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) { const tenantId = clinicTenantId.value; const uid = ownerId.value; const patientId = row.patient_id ?? row.paciente_id ?? null; const tasks = []; // 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim) if (decision.consumeSaldo && ctx.billingContract?.id) { tasks.push( supabase .from('billing_contracts') .update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1, updated_at: new Date().toISOString() }) .eq('id', ctx.billingContract.id) ); } // 2) Aplicar multa (cria financial_record avulsa) if (decision.applyFine && decision.fineAmount > 0) { const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const finePayload = { owner_id: uid, tenant_id: tenantId, patient_id: patientId, agenda_evento_id: eventoId, amount: decision.fineAmount, final_amount: decision.fineAmount, description: novoStatus === 'faltou' ? 'Multa por falta (no-show)' : 'Taxa de cancelamento', status: 'pending', due_date: dueIso, type: 'receita' }; tasks.push( supabase .from('financial_records') .insert(finePayload) .then(({ error }) => { if (error) { console.warn('[Fase5] INSERT multa falhou:', error?.message, 'payload:', finePayload); throw error; } }) ); } // 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status) if (decision.markPaid && ctx.pendingRecord?.id) { tasks.push( supabase .from('financial_records') .update({ status: 'paid', paid_at: new Date().toISOString(), payment_method: decision.paymentMethod || 'pix', updated_at: new Date().toISOString() }) .eq('id', ctx.pendingRecord.id) ); } // 4) Realizado em pacote saldo: cria cobrança individual + incrementa sessions_used if (decision.generatePackageCharge && ctx.billingContract?.id) { const amount = Number(row.price ?? 0); const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); // Cria record tasks.push( supabase.rpc('create_financial_record_for_session', { p_tenant_id: tenantId, p_owner_id: uid, p_patient_id: patientId, p_agenda_evento_id: eventoId, p_amount: amount, p_due_date: dueIso }) ); // Incrementa saldo usado tasks.push( supabase .from('billing_contracts') .update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1, updated_at: new Date().toISOString() }) .eq('id', ctx.billingContract.id) ); } // Roda tudo em paralelo (falha parcial é tolerável — toast warn) const results = await Promise.allSettled(tasks); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length > 0) { const firstErr = failed[0].reason?.message || 'sem detalhe'; toast.add({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 }); console.error('[Fase5] falhas em _applyStatusDecisions:', failed.map((f) => f.reason)); } else if (tasks.length > 0) { toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 }); } // Pós: se gerou cobrança via link Asaas, marcar payment_method='asaas' if (decision.generatePackageCharge && decision.paymentMethod === 'link' && eventoId) { try { const { data: newRec } = await supabase .from('financial_records') .select('id') .eq('agenda_evento_id', eventoId) .order('created_at', { ascending: false }) .limit(1) .single(); if (newRec?.id) { await supabase.from('financial_records').update({ payment_method: 'asaas', updated_at: new Date().toISOString() }).eq('id', newRec.id); } } catch { /* silencioso */ } } else if (decision.generatePackageCharge && decision.paymentMethod !== 'link' && eventoId) { // Já recebi → marca como paid try { const { data: newRec } = await supabase .from('financial_records') .select('id') .eq('agenda_evento_id', eventoId) .order('created_at', { ascending: false }) .limit(1) .single(); if (newRec?.id) { await supabase .from('financial_records') .update({ status: 'paid', paid_at: new Date().toISOString(), payment_method: decision.paymentMethod, updated_at: new Date().toISOString() }) .eq('id', newRec.id); } } catch { /* silencioso */ } } } // ── onDialogSave / onDialogDelete ── populados na Stage 2 ── const onDialogSave = _buildOnDialogSave(deps); const onDialogDelete = _buildOnDialogDelete(deps); return { onEditEvento, onCreateEvento, onCreateEventoForPatient, onSelectTime, persistMoveOrResize, onEditSeriesOccurrence, onUpdateSeriesEvent, onDialogSave, onDialogDelete }; } // ─────────────────────────────────────────────────────────────────────────── // onDialogSave — porte direto de AgendaTerapeutaPage.onDialogSave (linhas // 1714-2116). Cobre 7 cases: criação avulsa, sessão única (A/B), série nova // (C), edição "somente esta" (D), "este e seguintes" (E), "todos" (F), // "todos sem exceção" (G), além de tratamento explícito de exclusion // constraint (conflict de horário). // ─────────────────────────────────────────────────────────────────────────── function _buildOnDialogSave(deps) { const { toast, ownerId, clinicTenantId, settings, eventsError, create, update, createRule, updateRule, splitRuleAt, upsertException, saveRuleItems, propagateToSerie, dialogOpen, dialogEventRow, occDialogOpen, serieRefreshTick, _reloadRange } = deps; return async function onDialogSave(arg) { let normalized = null; // _occurrenceMode: payload veio do 2º dialog empilhado (editar UMA // ocorrencia da serie). Nesse caso, fechamos o dialog da OCORRENCIA // (nao o pai) e bumpamos serieRefreshTick pra que o pai re-rode // loadSerieEvents e a pill reflita o novo status. Sem isso, o pai // fechava silenciosamente e a pill ficava com status stale. 2026-05-12. const isOccurrence = !!arg?._occurrenceMode; function closeDialog() { if (isOccurrence) { occDialogOpen.value = false; serieRefreshTick.value++; } else { dialogOpen.value = false; } } try { const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload'); const payload = isWrapped ? arg.payload : arg; const recorrencia = arg?.recorrencia ?? null; const editMode = arg?.editMode ?? null; const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null; const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null; const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null); normalized = { ...(payload || {}) }; if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value; const clinicId = clinicTenantId.value; if (!clinicId) throw new Error('tenant_id da clínica não encontrado no tenantStore.'); normalized.tenant_id = clinicId; if (!normalized.visibility_scope) normalized.visibility_scope = 'public'; normalized.tipo = normalizeEventoTipo(normalized.tipo || deriveEventoTipoForNewEvent(normalized), EVENTO_TIPO.SESSAO); if (!normalized.status) normalized.status = 'agendado'; if (!String(normalized.titulo || '').trim()) normalized.titulo = deriveTituloDefaultByTipo(normalized.tipo); if (!normalized.paciente_id || !isUuid(normalized.paciente_id)) normalized.paciente_id = null; if (normalized.tipo === EVENTO_TIPO.BLOQUEIO) { normalized.paciente_id = null; normalized.determined_commitment_id = null; if (!normalized.visibility_scope) normalized.visibility_scope = 'busy_only'; } if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) { normalized.determined_commitment_id = null; } // ── CASO C / C2: criação RECORRENTE ─────────────────────────────── if (recorrencia?.tipo === 'recorrente' && !recurrenceId) { const startDate = new Date(normalized.inicio_em); const tipoFreq = recorrencia.tipoFreq ?? 'semanal'; const dow = recorrencia.diaSemana ?? startDate.getDay(); const firstRecISO = startDate.toISOString().slice(0, 10); let ruleType = 'weekly'; let interval = 1; let weekdays = [dow]; if (tipoFreq === 'quinzenal') { ruleType = 'weekly'; interval = 2; } else if (tipoFreq === 'diasEspecificos') { ruleType = 'custom_weekdays'; weekdays = recorrencia.diasSemana?.length ? recorrencia.diasSemana : [dow]; } const rule = { tenant_id: clinicId, owner_id: normalized.owner_id, therapist_id: normalized.terapeuta_id ?? null, patient_id: normalized.paciente_id ?? null, determined_commitment_id: normalized.determined_commitment_id ?? null, type: ruleType, interval, weekdays, start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8), end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50), duration_min: recorrencia.duracaoMin ?? 50, timezone: settings.value?.timezone || 'America/Sao_Paulo', start_date: firstRecISO, end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null, max_occurrences: recorrencia.qtdSessoes ?? null, open_ended: !recorrencia.dataFim && !recorrencia.qtdSessoes, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null, status: 'ativo' }; const createdRule = await createRule(rule); if (id && createdRule?.id) { await update(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO }); } if (recorrencia?.conflitos?.length && createdRule?.id) { const exceptions = recorrencia.conflitos.map((c) => ({ recurrence_id: createdRule.id, tenant_id: clinicId, original_date: c.date, type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session', reason: c.conflict.label })); const { error: exErr } = await supabase.from('recurrence_exceptions').insert(exceptions); if (exErr) console.warn('[useMelissaAgenda] exceptions insert', exErr); } if (createdRule?.id && recorrencia.commitmentItems?.length) { await saveRuleItems(createdRule.id, recorrencia.commitmentItems); } // Opção C1 (2026-05-13): cobrança decidida ANTES de salvar via // chargeMode no payload. Substituiu o confirm.require pós-save. // 'package' → 1 billing_contract com valor total da série // 'per_session' → materializa N agenda_eventos + N financial_records // 'none' → nada const recChargeMode = arg?.chargeMode || 'none'; let chargeInfo = null; if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) { if (recChargeMode === 'package') { chargeInfo = await _createPackageContract({ rule: createdRule, normalized, recorrencia, tenantId: clinicId, packageStyle: arg?.packageStyle || 'upfront', paymentMethod: arg?.paymentMethod || 'link', markPaidNow: arg?.markPaidNow === true }); } else if (recChargeMode === 'per_session') { chargeInfo = await _materializeAndChargePerSession({ rule: createdRule, normalized, recorrencia, tenantId: clinicId }); } } // chargeMode='none' (C6 doc): materializa a 1ª ocorrência // sem criar financial_record. As demais ficam virtuais. // Honra: "1ª materializada com badge $ a cobrar; outras 3 // virtuais expandidas em runtime, limpas até interação". // package/per_session ja materializam dentro dos proprios // helpers; package 'saldo' intencionalmente NÃO materializa // (doc C8 — modelo Cliniko, todas virtuais ate interacao). // IMPORTANTE: agendaRepository.createAgendaEvento dropa // 'paciente_id' (campo legado). USAR 'patient_id' (English), // que é o nome real da coluna em agenda_eventos. const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null; if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) { try { const durMin = createdRule.duration_min || 50; const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); const firstISO = createdRule.start_date; const startDt = new Date(`${firstISO}T00:00:00`); startDt.setHours(hh, mm, 0, 0); const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); await create({ owner_id: createdRule.owner_id, tenant_id: clinicId, terapeuta_id: createdRule.therapist_id ?? null, recurrence_id: createdRule.id, recurrence_date: firstISO, tipo: normalized.tipo || 'sessao', status: normalized.status || 'agendado', titulo: normalized.titulo || 'Sessão', inicio_em: startDt.toISOString(), fim_em: endDt.toISOString(), patient_id: _patientIdForFirst, determined_commitment_id: normalized.determined_commitment_id ?? null, modalidade: normalized.modalidade ?? 'presencial', observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null, visibility_scope: normalized.visibility_scope || 'public' }); } catch (e) { console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message); } } const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada'; toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 }); if (chargeInfo?.toast) toast.add(chargeInfo.toast); closeDialog(); await _reloadRange(); return; } // ── CASO D: edição "somente_este" ───────────────────────────────── if (recurrenceId && editMode === 'somente_este') { let eventId = id ?? null; if (id) { await update(id, pickDbFields(normalized)); if (originalDate) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicId, original_date: originalDate, type: 'reschedule_session', new_date: normalized.inicio_em?.slice(0, 10), new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null, new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null, modalidade: normalized.modalidade ?? null, titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null }); } } else if (originalDate) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicId, original_date: originalDate, type: 'reschedule_session', new_date: normalized.inicio_em?.slice(0, 10), new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null, new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null, modalidade: normalized.modalidade ?? null, titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null }); if (arg.onSaved) { const { data: existing } = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', recurrenceId) .eq('recurrence_date', originalDate) .maybeSingle(); if (existing?.id) { eventId = existing.id; } else { const mat = await create({ owner_id: normalized.owner_id, tenant_id: clinicId, recurrence_id: recurrenceId, recurrence_date: originalDate, tipo: normalized.tipo, status: normalized.status, inicio_em: normalized.inicio_em, fim_em: normalized.fim_em, titulo: normalized.titulo, patient_id: normalized.patient_id, determined_commitment_id: normalized.determined_commitment_id, modalidade: normalized.modalidade ?? 'presencial', observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); eventId = mat.id; } } } if (eventId) await arg.onSaved?.(eventId, { markCustomized: true }); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── CASO E: edição "este_e_seguintes" ───────────────────────────── if (recurrenceId && editMode === 'este_e_seguintes' && originalDate) { const newRuleId = await splitRuleAt(recurrenceId, originalDate); const startDate = new Date(normalized.inicio_em); await updateRule(newRuleId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); const serviceItemsE = arg.serviceItems; if (newRuleId && serviceItemsE?.length) { await saveRuleItems(newRuleId, serviceItemsE); await propagateToSerie(newRuleId, serviceItemsE, { fromDate: originalDate }); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── CASO F: edição "todos" ──────────────────────────────────────── if (recurrenceId && editMode === 'todos') { const startDate = new Date(normalized.inicio_em); await updateRule(recurrenceId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); await supabase .from('agenda_eventos') .update({ modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }) .eq('recurrence_id', recurrenceId); const serviceItemsF = arg.serviceItems; if (recurrenceId && serviceItemsF?.length) { await saveRuleItems(recurrenceId, serviceItemsF); await propagateToSerie(recurrenceId, serviceItemsF); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── CASO G: edição "todos_sem_excecao" ──────────────────────────── if (recurrenceId && editMode === 'todos_sem_excecao') { const startDate = new Date(normalized.inicio_em); await updateRule(recurrenceId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); await supabase .from('agenda_eventos') .update({ modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null, services_customized: false }) .eq('recurrence_id', recurrenceId); const serviceItemsG = arg.serviceItems; if (recurrenceId && serviceItemsG?.length) { await saveRuleItems(recurrenceId, serviceItemsG); await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true }); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas (sem exceção).', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── CASO A/B: evento avulso ou sessão única ────────────────────── const dbPayload = pickDbFields(normalized); let createdEventId = null; if (id) { await update(id, dbPayload); await arg.onSaved?.(id); createdEventId = id; toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 }); } else { const created = await create(dbPayload); await arg.onSaved?.(created.id); createdEventId = created.id; toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 }); } // Opção C1 (2026-05-13): cobrança avulsa via chargeMode='session'. // Cria 1 financial_record vinculado ao evento. Falha aqui não // reverte a sessão — toast warn pra gerar manualmente depois. // Refatorado 2026-05-16: paymentSettlement substituído por // paymentMethod + markPaidNow. Handler aplica payment_method sempre; // status='paid' apenas quando markPaidNow && method !== 'link'. if (arg?.chargeMode === 'session' && createdEventId) { try { // Convênio: valor vem em insurance_value (não price); // payment_method fixo em 'convenio' (não asaas/pix/etc) — // o plano paga a clínica no fechamento mensal, nunca via // gateway ou no caixa. markPaidNow ignorado pra convênio // (record sempre nasce pending até a baixa via Financeiro). const isConvenio = !!normalized.insurance_plan_id; const amount = isConvenio ? (normalized.insurance_value ?? 0) : (normalized.price ?? 0); const dueDate = normalized.inicio_em ? new Date(normalized.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { p_tenant_id: clinicId, p_owner_id: normalized.owner_id, p_patient_id: normalized.paciente_id ?? null, p_agenda_evento_id: createdEventId, p_amount: amount, p_due_date: dueDate }); if (cobErr) throw cobErr; // Pós-RPC: ajusta payment_method (sempre) e status (só se // markPaidNow=true e método direto). Convênio força method // = 'convenio' e ignora markPaidNow. // convenio → payment_method='convenio', status=pending // method='link' → payment_method='asaas', status=pending // method=pix/etc + markPaidNow=false → payment_method=<>, status=pending // method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now() const method = isConvenio ? 'convenio' : (arg?.paymentMethod || 'link'); const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link'; try { const { data: recRow } = await supabase .from('financial_records') .select('id') .eq('agenda_evento_id', createdEventId) .order('created_at', { ascending: false }) .limit(1) .single(); if (recRow?.id) { const patch = { updated_at: new Date().toISOString(), payment_method: method === 'link' ? 'asaas' : method }; if (paidNow) { patch.status = 'paid'; patch.paid_at = new Date().toISOString(); } const { error: upErr } = await supabase.from('financial_records').update(patch).eq('id', recRow.id); if (upErr) throw upErr; } } catch (e2) { console.warn('[useMelissaAgenda] pos-cobranca update falhou:', e2?.message); } const methodLabel = { pix: 'PIX', dinheiro: 'dinheiro', deposito: 'depósito', cartao_maquininha: 'cartão (maquininha)', convenio: 'convênio' }[method] || null; toast.add({ severity: 'success', summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada', detail: paidNow ? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.` : `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`, life: 3500 }); } catch (e) { toast.add({ severity: 'warn', summary: 'Sessão salva, mas cobrança falhou', detail: e?.message || 'Você pode gerar manualmente pelo Financeiro.', life: 5000 }); } } closeDialog(); await _reloadRange(); } catch (e) { const msg = String(e?.message || ''); if (msg.includes('recurrence_rules_dates_chk') || (msg.includes('violates check constraint') && msg.includes('recurrence_rules'))) { toast.add({ severity: 'warn', summary: 'Não foi possível dividir a série', detail: 'Esta é a primeira sessão da série. Para alterar todas as ocorrências, selecione "Todos" ou "Todos sem exceção".', life: 6000 }); return; } const isOverlap = e?.code === '23P01' || msg.includes('agenda_eventos_sem_sobreposicao') || msg.includes('exclusion constraint') || msg.includes('conflicting key value violates exclusion constraint'); if (isOverlap) { let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.'; try { if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) { const { data: conflicting } = await supabase .from('agenda_eventos') .select('titulo, inicio_em, fim_em') .eq('owner_id', normalized.owner_id) .lt('inicio_em', normalized.fim_em) .gt('fim_em', normalized.inicio_em) .limit(1) .maybeSingle(); if (conflicting) { const ini = new Date(conflicting.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const fim = new Date(conflicting.fim_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const titulo = conflicting.titulo || 'Compromisso'; detail = `Conflito com "${titulo}" (${ini} → ${fim}). Ajuste o horário ou a duração.`; } } } catch { /* mantém detail genérico */ } toast.add({ severity: 'warn', summary: 'Conflito de horário', detail, life: 7000 }); return; } toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar.', life: 4500 }); } }; } // ─────────────────────────────────────────────────────────────────────────── // onDialogDelete — porte direto de AgendaTerapeutaPage:2118-2204 // ─────────────────────────────────────────────────────────────────────────── function _buildOnDialogDelete(deps) { const { toast, ownerId, clinicTenantId, create, update, remove, cancelRule, cancelRuleFrom, upsertException, dialogOpen, dialogEventRow, occDialogOpen, serieRefreshTick, eventsError, _reloadRange } = deps; return async function onDialogDelete(arg) { const id = typeof arg === 'string' ? arg : arg?.id; const editMode = typeof arg === 'string' ? null : arg?.editMode; const recurrenceId = typeof arg === 'string' ? null : (arg?.recurrence_id ?? arg?.serie_id ?? null); const originalDate = typeof arg === 'string' ? null : (arg?.original_date ?? dialogEventRow.value?.original_date ?? null); // Mesma logica do save: delete acionado do 2º dialog empilhado fecha // a ocorrencia + bumpa pill refresh; do pai fecha o pai. 2026-05-12. const isOccurrence = typeof arg === 'object' && !!arg?._occurrenceMode; function closeDialog() { if (isOccurrence) { occDialogOpen.value = false; serieRefreshTick.value++; } else { dialogOpen.value = false; } } try { // ── Somente este evento / ocorrência ── if (!recurrenceId || editMode === 'somente_este') { if (originalDate && recurrenceId) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicTenantId.value, original_date: originalDate, type: 'cancel_session' }); } else if (id) { await remove(id); } toast.add({ severity: 'success', summary: 'Excluído', detail: 'Sessão removida.', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── Este e os seguintes ── if (editMode === 'este_e_seguintes' && originalDate) { await cancelRuleFrom(recurrenceId, originalDate); toast.add({ severity: 'success', summary: 'Excluído', detail: 'Esta sessão e as seguintes foram canceladas.', life: 2500 }); closeDialog(); await _reloadRange(); return; } // ── Todos (encerrar série, manter sessão atual como avulsa) ── if (editMode === 'todos') { const row = dialogEventRow.value || {}; const isVirtual = row.is_occurrence && !id; if (isVirtual) { const rDate = row.original_date || row.inicio_em?.slice(0, 10); const existing = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', recurrenceId) .eq('recurrence_date', rDate) .maybeSingle(); if (existing.data?.id) { await update(existing.data.id, { recurrence_id: null, recurrence_date: null }); } else { await create({ owner_id: ownerId.value, tenant_id: clinicTenantId.value, tipo: row.tipo || 'sessao', status: row.status || 'agendado', inicio_em: row.inicio_em, fim_em: row.fim_em, titulo: row.titulo || 'Sessão', patient_id: row.patient_id || row.paciente_id || null, determined_commitment_id: row.determined_commitment_id || null, modalidade: row.modalidade || 'presencial', price: row.price ?? null, observacoes: row.observacoes || null, visibility_scope: 'public' }); } } else if (id) { await update(id, { recurrence_id: null, recurrence_date: null }); } await cancelRule(recurrenceId); toast.add({ severity: 'success', summary: 'Série encerrada', detail: 'A série foi encerrada. Esta sessão foi mantida como avulsa.', life: 3000 }); closeDialog(); await _reloadRange(); return; } // fallback if (id) await remove(id); toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 }); closeDialog(); await _reloadRange(); } catch (e) { toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 }); } }; } // ─────────────────────────────────────────────────────────────────────────── // Helpers de cobrança em série (Opção C1, 2026-05-13) // ─────────────────────────────────────────────────────────────────────────── const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); // Calcula valor total da série a partir dos commitmentItems. function _computeSeriePrice(recorrencia) { const items = recorrencia.commitmentItems || []; const n = recorrencia.qtdSessoes; const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0); const pacoteFechado = recorrencia.serieValorMode === 'dividir'; return { n, perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao, packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n }; } // chargeMode='package' — 2 estilos (2026-05-14): // - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência // em agenda_eventos + cria 1 financial_record com valor TOTAL do pacote // (vencimento na data da 1ª sessão). Demais ocorrências continuam virtuais. // Suporta paymentMethod + markPaidNow — marca record como pago quando true. // - 'saldo': só cria billing_contract (Cliniko style). Sem financial_record // imediato — cobranças individuais nascem conforme sessões. async function _createPackageContract({ rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) { const { n, packagePrice } = _computeSeriePrice(recorrencia); try { // 1) billing_contract — referência do pacote em ambos os estilos. // charging_style: identifica como o pacote foi cobrado na criação; // handler de status change usa pra decidir entre "só status" (upfront) // ou "criar cobrança + consumir saldo" (saldo). const { data: createdContract, error: contractErr } = await supabase .from('billing_contracts') .insert({ owner_id: normalized.owner_id, tenant_id: tenantId, patient_id: normalized.paciente_id, type: 'package', total_sessions: n, sessions_used: 0, package_price: packagePrice, status: 'active', charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront' }) .select('id') .single(); if (contractErr) throw contractErr; const contractId = createdContract?.id ?? null; // Estilo 'saldo': para aqui — sem cobrança imediata. if (packageStyle === 'saldo') { return { toast: { severity: 'success', summary: 'Pacote criado (saldo)', detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`, life: 3500 } }; } // Estilo 'upfront': materializa 1ª ocorrência + 1 financial_record total. const durMin = rule.duration_min || 50; const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); const firstISO = rule.start_date; const startDt = new Date(`${firstISO}T00:00:00`); startDt.setHours(hh, mm, 0, 0); const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); const { data: createdEvent, error: evErr } = await supabase .from('agenda_eventos') .insert({ owner_id: rule.owner_id, tenant_id: tenantId, terapeuta_id: rule.therapist_id ?? null, recurrence_id: rule.id, recurrence_date: firstISO, tipo: 'sessao', status: 'agendado', titulo: normalized.titulo || 'Sessão', inicio_em: startDt.toISOString(), fim_em: endDt.toISOString(), patient_id: normalized.paciente_id, determined_commitment_id: normalized.determined_commitment_id ?? null, modalidade: rule.modalidade || normalized.modalidade || 'presencial', price: packagePrice, billing_contract_id: contractId, visibility_scope: normalized.visibility_scope || 'public' }) .select('id') .single(); if (evErr) throw evErr; // 2) financial_record do pacote total via RPC. const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { p_tenant_id: tenantId, p_owner_id: rule.owner_id, p_patient_id: normalized.paciente_id ?? null, p_agenda_evento_id: createdEvent.id, p_amount: packagePrice, p_due_date: firstISO }); if (cobErr) throw cobErr; // 3) Pós-RPC: ajusta payment_method (sempre) e status (só se markPaidNow=true). // method='link' → payment_method='asaas', status pending // method=pix/etc + markPaidNow=false → payment_method=<>, status pending // method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now() const paidNow = markPaidNow === true && paymentMethod !== 'link'; const { data: recRow } = await supabase .from('financial_records') .select('id') .eq('agenda_evento_id', createdEvent.id) .order('created_at', { ascending: false }) .limit(1) .single(); if (recRow?.id) { const patch = { updated_at: new Date().toISOString(), payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod }; if (paidNow) { patch.status = 'paid'; patch.paid_at = new Date().toISOString(); } await supabase.from('financial_records').update(patch).eq('id', recRow.id); } const methodLabel = { pix: 'PIX', dinheiro: 'dinheiro', deposito: 'depósito', cartao_maquininha: 'cartão (maquininha)' }[paymentMethod] || null; return { toast: { severity: 'success', summary: paidNow ? 'Pacote pago' : 'Pacote criado', detail: paidNow ? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.` : `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`, life: 4000 } }; } catch (e) { return { toast: { severity: 'warn', summary: 'Pacote não gerado', detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.', life: 5000 } }; } } // chargeMode='per_session': materializa todas as N ocorrências como // agenda_eventos reais + cria 1 financial_record por ocorrência. // Respeita recurrence_exceptions (feriado_block / cancel_session) — não // materializa nessas datas. Falha parcial é tolerada (toast warn). async function _materializeAndChargePerSession({ rule, normalized, recorrencia, tenantId }) { const { n, perSessao } = _computeSeriePrice(recorrencia); try { // 1) Gerar a lista de datas das N ocorrências respeitando exceptions. const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date)); const dates = _generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n); // 2) Montar rows pra inserção em agenda_eventos. const durMin = rule.duration_min || 50; const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number); const rows = dates.map((iso) => { const startDt = new Date(`${iso}T00:00:00`); startDt.setHours(hh, mm, 0, 0); const endDt = new Date(startDt.getTime() + durMin * 60 * 1000); return { owner_id: rule.owner_id, tenant_id: tenantId, terapeuta_id: rule.therapist_id ?? null, recurrence_id: rule.id, recurrence_date: iso, tipo: 'sessao', status: 'agendado', titulo: normalized.titulo || 'Sessão', inicio_em: startDt.toISOString(), fim_em: endDt.toISOString(), patient_id: normalized.paciente_id, determined_commitment_id: normalized.determined_commitment_id ?? null, modalidade: rule.modalidade || normalized.modalidade || 'presencial', price: perSessao, visibility_scope: normalized.visibility_scope || 'public' }; }); // 3) Insert batch dos eventos. const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em'); if (evErr) throw evErr; // 4) Pra cada evento criado, criar financial_record via RPC. Loop // sequencial pra simplificar — N=4 é pouco; se virar gargalo, batchify. let okCount = 0; let failCount = 0; for (const ev of createdEvents || []) { try { const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', { p_tenant_id: tenantId, p_owner_id: rule.owner_id, p_patient_id: normalized.paciente_id ?? null, p_agenda_evento_id: ev.id, p_amount: perSessao, p_due_date: dueDate }); if (cobErr) throw cobErr; okCount++; } catch { failCount++; } } if (failCount === 0) { return { toast: { severity: 'success', summary: `${okCount} cobranças geradas`, detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`, life: 4000 } }; } return { toast: { severity: 'warn', summary: 'Cobranças parcialmente geradas', detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`, life: 6000 } }; } catch (e) { return { toast: { severity: 'warn', summary: 'Falha ao materializar série', detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.', life: 6000 } }; } } // Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas // em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1 // ou 2 pra quinzenal) e custom_weekdays. function _generateOccurrenceDates(rule, max, exceptionDates) { const dates = []; const start = new Date(`${rule.start_date}T00:00:00`); const interval = Math.max(1, rule.interval || 1); const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()]; const isCustom = rule.type === 'custom_weekdays'; const cursor = new Date(start); let safety = 0; // Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14. // Para custom_weekdays, avança 1 dia e filtra weekdays.includes. while (dates.length < max && safety < 365 * 3) { const iso = _dateToISO(cursor); const dow = cursor.getDay(); const inWeekdays = weekdays.includes(dow); if (inWeekdays && !exceptionDates.has(iso)) { dates.push(iso); } if (isCustom) { cursor.setDate(cursor.getDate() + 1); } else if (inWeekdays) { // weekly/quinzenal: ao bater o dow, pula interval semanas cursor.setDate(cursor.getDate() + 7 * interval); } else { cursor.setDate(cursor.getDate() + 1); } safety++; } return dates; } function _dateToISO(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; }