/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Arquivo: src/features/agenda/composables/useAgendaEventLifecycle.js | Data: 2026-05-04 | | A66 sub-sessão 1C-ii-b — lifecycle do AgendaEventDialog: | - Watcher props.modelValue (init form ao abrir — orquestra | loadPatients/ensureServicesLoaded/loadInsurancePlans/ | _loadCommitmentItemsForEvent + reset de refs) | - Watcher [tenantId, restrictPatients, patientScopeOwnerId] | - Watcher [dia, startTime] (solicitação pendente do agendador público) | - Watcher [dia, modalidade] (online slots loader) | - Series pills (loadSerieEvents + 4 handlers + generateRuleDates) | - selectSlot | - Quick-creates wiring (service + insurance) | - onSendManualReminder (lembrete WhatsApp) | | Recebe via argumento: | composer — composer (1B) | actions — actions (1C-i): _skipStatusWatch, _restoringConvenio, | samePatientConflict | pickerBilling — picker/billing (1C-ii-a): ensureServicesLoaded, | _loadCommitmentItemsForEvent, clearPatientsCache, | loadPatients, addItem | commitmentItems — ref | serieEvents — ref | servicePickerSel — ref do picker | selectedPlanService — ref do procedure de convênio | serieValorMode — ref<'multiplicar' | 'dividir'> | services — ref (de useServices) | loadServices — fn(ownerId) | loadInsurancePlans — fn(ownerId) | props — props do dialog | emit — emitter ('updateSeriesEvent', 'editSeriesOccurrence', 'delete') | confirm — useConfirm() | toast — useToast() |-------------------------------------------------------------------------- */ import { ref, computed, watch, nextTick } from 'vue'; import { supabase } from '@/lib/supabase/client'; export function generateRuleDates(rule) { const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {}; if (!start_date || !weekdays?.length) return []; const maxOcc = Math.min(max_occurrences || 365, 365); const endLimit = end_date ? new Date(end_date + 'T23:59:59') : null; const dates = []; if (type === 'custom_weekdays') { const cursor = new Date(start_date + 'T12:00:00'); let safety = 0; while (dates.length < maxOcc && safety < 2000) { safety++; if (endLimit && cursor > endLimit) break; if (weekdays.includes(cursor.getDay())) dates.push(cursor.toISOString().slice(0, 10)); cursor.setDate(cursor.getDate() + 1); } } else { // weekly (interval=1) ou quinzenal (interval=2) const cursor = new Date(start_date + 'T12:00:00'); while (dates.length < maxOcc) { if (endLimit && cursor > endLimit) break; dates.push(cursor.toISOString().slice(0, 10)); cursor.setDate(cursor.getDate() + 7 * (interval || 1)); } } return dates; } export function useAgendaEventLifecycle({ composer, actions, pickerBilling, commitmentItems, serieEvents, servicePickerSel, selectedPlanService, serieValorMode, services, loadServices, loadInsurancePlans, props, emit, confirm, toast }) { // ── refs locais ──────────────────────────────────────────── const solicitacaoPendente = ref(null); const onlineSlots = ref([]); const loadingOnlineSlots = ref(false); const serieLoading = ref(false); const pillDeleteMenuRef = ref(null); const pillDeleteTarget = ref(null); const sendingReminder = ref(false); const serviceQuickDlgOpen = ref(false); const insuranceQuickDlgOpen = ref(false); const planServiceQuickDlgOpen = ref(false); // occurrenceMode: financial_record da ocorrencia atual (se existir). // Usado pra travar edicao de tipo/servicos quando ja ha cobranca emitida // (padrao SimplePractice — cobranca emitida e imutavel; ajustes via fluxo // do Financeiro, nao via dialog). 2026-05-12. const occFinancialRecord = ref(null); const occFinancialLoading = ref(false); // sessionPaymentRecord (2026-05-18): financial_record da sessão (mesmo // shape do occFinancialRecord) mas SEM o guard de occurrenceMode. // Carregado em qualquer edit de sessão pra alimentar a linha "Cobrança" // do Resumo lateral do AgendaEventDialog. Não dispara lock — esse // continua via occFinancialRecord (território da Fase 6/C13). const sessionPaymentRecord = ref(null); // ── computeds locais ─────────────────────────────────────── const serieCountByStatus = computed(() => { const counts = {}; for (const ev of serieEvents.value) { const s = ev._status || 'agendado'; counts[s] = (counts[s] || 0) + 1; } return counts; }); const pillDeleteMenuItems = computed(() => { if (!pillDeleteTarget.value) return []; const ev = pillDeleteTarget.value; return [ { label: 'Remover apenas esta', icon: 'pi pi-minus-circle', command: () => onPillDelete(ev, 'somente_este') }, { label: 'Remover esta e as seguintes', icon: 'pi pi-forward', command: () => onPillDelete(ev, 'este_e_seguintes') }, { separator: true }, { label: 'Remover todas as futuras', icon: 'pi pi-trash', command: () => onPillDelete(ev, 'todos') } ]; }); // ── series pills ─────────────────────────────────────────── async function loadSerieEvents() { const rid = props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null; if (!rid) { serieEvents.value = []; return; } serieLoading.value = true; try { const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle(); if (ruleErr) throw ruleErr; const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid); const exMap = new Map((excData || []).map((e) => [e.original_date, e])); const { data: realData } = await supabase .from('agenda_eventos') .select('id, inicio_em, fim_em, status, recurrence_date') .eq('recurrence_id', rid) .is('mirror_of_event_id', null) .order('inicio_em', { ascending: true }); const realMap = new Map((realData || []).map((e) => [e.recurrence_date || e.inicio_em?.slice(0, 10), e])); const dates = rule ? generateRuleDates(rule) : []; const startTime = rule?.start_time || '00:00:00'; const durMin = rule?.duration_min || 50; const list = dates.map((dateISO) => { const real = realMap.get(dateISO); const exc = exMap.get(dateISO); const isCancelled = exc?.type === 'cancel_session' || exc?.type === 'holiday_block'; const inicioStr = real?.inicio_em || `${dateISO}T${startTime}`; const fimDate = new Date(`${dateISO}T${startTime}`); fimDate.setMinutes(fimDate.getMinutes() + durMin); const fimStr = real?.fim_em || fimDate.toISOString(); return { id: real?.id || null, inicio_em: inicioStr, fim_em: fimStr, status: real?.status || (isCancelled ? 'cancelado' : 'agendado'), recurrence_date: dateISO, _status: real?.status || (isCancelled ? 'cancelado' : 'agendado'), _is_virtual: !real?.id, _cancelled: isCancelled, _reason: exc?.reason || null }; }); for (const [dateISO, real] of realMap) { if (!dates.includes(dateISO)) { list.push({ id: real.id, inicio_em: real.inicio_em, fim_em: real.fim_em, status: real.status || 'agendado', recurrence_date: dateISO, _status: real.status || 'agendado', _is_virtual: false, _cancelled: false, _reason: null }); } } list.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em)); serieEvents.value = list; } catch (e) { console.error('[serie] erro ao carregar:', e); serieEvents.value = []; } finally { serieLoading.value = false; } } // ── occurrence financial record loader ──────────────────── async function loadOccFinancialRecord() { occFinancialRecord.value = null; if (!props.occurrenceMode) return; const evId = props.eventRow?.id; if (!evId) return; occFinancialLoading.value = true; try { const { data, error } = await supabase .from('financial_records') .select('id, amount, final_amount, status, due_date, paid_at, payment_method') .eq('agenda_evento_id', evId) .in('status', ['pending', 'paid', 'overdue']) .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); if (error) throw error; occFinancialRecord.value = data ?? null; } catch (e) { console.warn('[occurrence] erro ao carregar financial_record:', e?.message); occFinancialRecord.value = null; } finally { occFinancialLoading.value = false; } } // sessionPaymentRecord loader (2026-05-18): mesma query, sem guard // de occurrenceMode. Alimenta a linha "Cobrança" do Resumo do dialog // em qualquer edit de sessão (Melissa/Rail/Clínica) com eventRow.id. async function loadSessionPaymentRecord() { sessionPaymentRecord.value = null; const evId = props.eventRow?.id; if (!evId) return; try { const { data, error } = await supabase .from('financial_records') .select('id, amount, final_amount, status, due_date, paid_at, payment_method') .eq('agenda_evento_id', evId) .in('status', ['pending', 'paid', 'overdue']) .order('created_at', { ascending: false }) .limit(1) .maybeSingle(); if (error) throw error; sessionPaymentRecord.value = data ?? null; } catch (e) { console.warn('[session-payment] erro ao carregar financial_record:', e?.message); sessionPaymentRecord.value = null; } } function onPillEditClick(ev) { emit('editSeriesOccurrence', { id: ev.id, recurrence_date: ev.recurrence_date, inicio_em: ev.inicio_em, fim_em: ev.fim_em, is_virtual: ev._is_virtual }); } function onPillStatusChange(ev) { emit('updateSeriesEvent', { id: ev.id, status: ev._status, recurrence_date: ev.recurrence_date, inicio_em: ev.inicio_em, fim_em: ev.fim_em, is_virtual: ev._is_virtual }); if (ev._is_virtual) { setTimeout(() => loadSerieEvents(), 700); } } function onPillDeleteClick(ev, event) { pillDeleteTarget.value = ev; nextTick(() => pillDeleteMenuRef.value?.toggle(event)); } function onPillDelete(ev, mode) { const isTodos = mode === 'todos'; confirm.require({ header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente', message: isTodos ? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.' : mode === 'este_e_seguintes' ? 'Esta sessão e todas as seguintes serão removidas. Tem certeza?' : 'Esta sessão será cancelada. Tem certeza?', icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle', acceptClass: 'p-button-danger', acceptLabel: isTodos ? 'Sim, encerrar série' : 'Confirmar', rejectLabel: 'Cancelar', accept: () => emit('delete', { id: ev.id, editMode: mode, recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null, original_date: ev.recurrence_date || (ev.inicio_em ? ev.inicio_em.slice(0, 10) : null), serie_id: props.eventRow?.serie_id ?? null }) }); } // ── slot selection ───────────────────────────────────────── function selectSlot(hhmm) { const [h, m] = String(hhmm).split(':').map(Number); const d = new Date(); d.setHours(h, m, 0, 0); composer.startTimeDate.value = d; } // ── quick-creates ────────────────────────────────────────── function openServiceQuickCreate() { serviceQuickDlgOpen.value = true; } async function onServiceCreated(svc) { await loadServices(props.ownerId); if (svc?.id) { const list = services?.value; const fresh = (Array.isArray(list) ? list.find((s) => s.id === svc.id) : null) || svc; if (typeof pickerBilling.addItem === 'function') { pickerBilling.addItem(fresh); } } } function openInsuranceQuickCreate() { insuranceQuickDlgOpen.value = true; } async function onInsuranceCreated(plan) { await loadInsurancePlans(props.planOwnerId || props.ownerId); if (plan?.id) { composer.form.value.insurance_plan_id = plan.id; } } // Quick-create de procedimento (insurance_plan_services) — inline, // sem sair do dialog. Trigger no card Sessao/Honorarios quando o // convenio selecionado nao tem procedimentos ou quando user quer // adicionar mais. Apos criar, recarrega os planos pra refletir no // computed planServices. function openPlanServiceQuickCreate() { if (!composer.form.value.insurance_plan_id) return; planServiceQuickDlgOpen.value = true; } async function onPlanServiceCreated(service) { await loadInsurancePlans(props.planOwnerId || props.ownerId); // Auto-seleciona o procedimento recem-criado se o user nao // tinha nenhum selecionado ainda (caso comum: convenio sem // procedimentos -> cadastra o primeiro -> ja entra selecionado). if (service?.id && !pickerBilling.selectedPlanService.value) { pickerBilling.selectedPlanService.value = service.id; pickerBilling.onProcedureSelect(service.id); } } // ── lembrete WhatsApp manual (8.2) ───────────────────────── async function onSendManualReminder() { if (!composer.form.value?.id) return; confirm.require({ header: 'Enviar lembrete WhatsApp?', message: `Vou mandar o template "lembrete_sessao" pra ${composer.form.value.paciente_nome || 'o paciente'} agora. Pode disparar?`, icon: 'pi pi-whatsapp', acceptLabel: 'Enviar', rejectLabel: 'Cancelar', accept: async () => { sendingReminder.value = true; try { const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', { body: { event_id: composer.form.value.id } }); if (error || !data?.ok) { const err = data?.error || error?.message || 'unknown_error'; let friendly = err; if (err === 'no_phone') friendly = 'Paciente sem telefone cadastrado.'; else if (err === 'invalid_phone') friendly = 'Telefone do paciente inválido.'; else if (err === 'no_active_channel') friendly = 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.'; else if (err === 'template_not_found') friendly = 'Template "lembrete_sessao" não encontrado. Configure em Configurações → WhatsApp.'; else if (err === 'forbidden') friendly = 'Você não tem permissão pra enviar por este canal.'; else if (String(err).startsWith('send_failed')) friendly = 'Não conseguimos enviar. Verifique a conexão do WhatsApp.'; throw new Error(friendly); } toast.add({ severity: 'success', summary: 'Lembrete enviado', detail: data.to ? `Para ${data.to}` : undefined, life: 3500 }); } catch (e) { toast.add({ severity: 'error', summary: 'Erro ao enviar lembrete', detail: e.message, life: 5000 }); } finally { sendingReminder.value = false; } } }); } // ── watchers ─────────────────────────────────────────────── // Init form ao abrir o dialog (orquestra tudo) watch( () => props.modelValue, async (open) => { if (!open) return; await nextTick(); actions._skipStatusWatch.value = true; composer.form.value = composer.resetForm(); await nextTick(); actions._skipStatusWatch.value = false; actions.samePatientConflict.value = null; composer.recorrenciaType.value = 'avulsa'; composer.diasSelecionados.value = []; composer.dataLimiteManual.value = null; composer.qtdSessoesMode.value = '4'; composer.qtdSessoesCustom.value = 12; composer.editScope.value = 'somente_este'; if (serieValorMode) serieValorMode.value = 'multiplicar'; if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) { supabase .from('patients') .select('id, nome_completo') .eq('id', composer.form.value.paciente_id) .maybeSingle() .then(({ data }) => { if (data?.nome_completo) composer.form.value.paciente_nome = data.nome_completo; }); } if (composer.hasSerie.value) loadSerieEvents(); else serieEvents.value = []; // occurrenceMode: carrega financial_record desta ocorrencia // pra decidir se o card Sessao/Honorarios fica locked (cobranca // ja emitida) ou unlocked (sem cobranca, edicao livre). loadOccFinancialRecord(); // sessionPaymentRecord: carrega em qualquer edit (Melissa // tambem) pra alimentar a linha "Cobrança" do Resumo lateral. loadSessionPaymentRecord(); // occurrenceMode: editando UMA ocorrencia de serie ja existente — // tipo de compromisso ja foi escolhido (paciente + sessao). Pular // step 1 incondicionalmente. Defesa em camadas: useMelissaAgenda // ja seta is_occurrence=true na row (faz isEdit=true), mas se outro // call site esquecer essa flag o guard aqui salva. if (props.occurrenceMode || composer.isEdit.value) { composer.step.value = 2; } else { const preset = props.presetCommitmentId; if (preset) { composer.form.value.commitment_id = preset; composer.step.value = 2; } else composer.step.value = 1; } pickerBilling.clearPatientsCache(); if (composer.requiresPatient.value) pickerBilling.loadPatients(true); pickerBilling.ensureServicesLoaded(); const insuranceOwner = props.planOwnerId || props.ownerId; if (insuranceOwner) { await loadInsurancePlans(insuranceOwner); } selectedPlanService.value = null; actions._restoringConvenio.value = false; commitmentItems.value = []; servicePickerSel.value = null; if (composer.isEdit.value && (composer.form.value.id || props.eventRow?.recurrence_id)) { pickerBilling._loadCommitmentItemsForEvent(composer.form.value.id); } else { composer.billingType.value = 'particular'; } } ); // Tenant/scope mudou — recarrega lista de pacientes watch( () => [props.tenantId, props.restrictPatientsToOwner, props.patientScopeOwnerId], () => { if (!composer.visible.value) return; pickerBilling.clearPatientsCache(); if (composer.requiresPatient.value) pickerBilling.loadPatients(true); } ); // Solicitação pendente do agendador público no horário escolhido watch( () => [composer.form.value.dia?.toString(), composer.form.value.startTime], async ([dia, startTime]) => { solicitacaoPendente.value = null; if (!composer.isSessionEvent.value || !composer.visible.value || composer.isEdit.value) return; if (!props.ownerId || !dia || !startTime) return; const d = new Date(composer.form.value.dia); const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const { data } = await supabase .from('agendador_solicitacoes') .select('id, paciente_nome, paciente_sobrenome, paciente_email') .eq('owner_id', props.ownerId) .eq('status', 'pendente') .eq('data_solicitada', isoDate) .eq('hora_solicitada', startTime) .maybeSingle(); solicitacaoPendente.value = data || null; } ); // Online slots: depende de [dia, modalidade] watch( [() => composer.form.value.dia, () => composer.form.value.modalidade], async ([dia, mod]) => { if (mod !== 'online' || !dia || !props.ownerId) { onlineSlots.value = []; return; } const dow = new Date(dia).getDay(); loadingOnlineSlots.value = true; try { const { data } = await supabase .from('agenda_online_slots') .select('time') .eq('owner_id', props.ownerId) .eq('weekday', dow) .eq('enabled', true) .order('time'); onlineSlots.value = (data || []).map((s) => ({ hhmm: String(s.time || '').slice(0, 5) })); } catch { onlineSlots.value = []; } finally { loadingOnlineSlots.value = false; } }, { immediate: true } ); return { // refs solicitacaoPendente, onlineSlots, loadingOnlineSlots, serieLoading, pillDeleteMenuRef, pillDeleteTarget, sendingReminder, serviceQuickDlgOpen, insuranceQuickDlgOpen, planServiceQuickDlgOpen, occFinancialRecord, occFinancialLoading, sessionPaymentRecord, // computeds serieCountByStatus, pillDeleteMenuItems, // series loadSerieEvents, loadOccFinancialRecord, loadSessionPaymentRecord, onPillEditClick, onPillStatusChange, onPillDeleteClick, onPillDelete, // slot selectSlot, // quick-creates openServiceQuickCreate, onServiceCreated, openInsuranceQuickCreate, onInsuranceCreated, openPlanServiceQuickCreate, onPlanServiceCreated, // reminder onSendManualReminder }; }