/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Arquivo: src/features/agenda/composables/useAgendaEventActions.js | Data: 2026-05-04 | | Watchers + handlers de save/delete extraídos do AgendaEventDialog.vue | (A66 sub-sessão 1C-i). Contém SIDE-EFFECTS (supabase, confirm, emit, toast) | — diferente do composer (1B) que é só state + computeds derivados. | | Escopo da 1C-i: | - Watcher do form.status (confirm dialog cancelado/remarcado + supabase update) | - Watcher do billingType (limpa campos por tipo) | - Watcher [paciente_id, dia] (detecta conflito do mesmo paciente no dia) | - onSave (monta payload + emit) | - onDelete (avulsa OU série com confirm) | - onEncerrarSerie (confirm de encerramento série inteira) | | Não inclui (vai pra 1C-ii): | - Watcher do props.modelValue (init form ao abrir — depende de loadPatients, | ensureServicesLoaded, loadInsurancePlans, _loadCommitmentItemsForEvent) | - Patient picker handlers (loadPatients, selectPaciente, ...) | - Billing/items handlers (addItem, removeItem, ...) | - Series pills handlers | - Slot selection | | Recebe via argumento: | composer — resultado de useAgendaEventComposer (form, canSave, etc) | commitmentItems — ref dos serviços/billing | servicePickerSel — ref do select picker | selectedPlanService — ref do procedure de convênio | saveCommitmentItems — function de useCommitmentServices (callback do save) | props, emit — do componente parent |-------------------------------------------------------------------------- */ import { ref, watch } from 'vue'; import { useToast } from 'primevue/usetoast'; import { useConfirm } from 'primevue/useconfirm'; import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; import { labelStatusSessao } from './agendaEventHelpers'; const EVENTO_TIPO_SESSAO = 'sessao'; export function useAgendaEventActions({ composer, commitmentItems, servicePickerSel, selectedPlanService, saveCommitmentItems, // chargeMode (Opção C1, 2026-05-13): ref string com modo de cobrança. // Valores: 'none' | 'session' (avulsa) | 'package' | 'per_session' (recorrente). // O emit('save') leva chargeMode no payload; handler em useMelissaAgenda // decide o que criar (financial_record | billing_contract | N events+records). // Substituiu o boolean gerarCobrancaAoSalvar. chargeMode, // packageStyle (2026-05-14): só relevante em chargeMode='package'. // Valores: 'upfront' (cria 1 financial_record total + materializa 1ª ocorrência) // | 'saldo' (só billing_contract, sem financial_record imediato — Cliniko). packageStyle, // paymentMethod (refatorado 2026-05-16): forma de recebimento quando // avulsa+session OU pacote+upfront. Valores: 'link' (Asaas, status pending) // | 'pix' | 'dinheiro' | 'deposito' | 'cartao_maquininha'. Status do // record é controlado pelo markPaidNow abaixo, não pela forma. paymentMethod, // markPaidNow (refatorado 2026-05-16): boolean. Quando true E método !== 'link', // handler marca o financial_record como paid (paciente pagou na hora). // Quando false, record nasce pending independente do método. markPaidNow, props, emit }) { const toast = useToast(); const confirm = useConfirm(); // Refs internos compartilhados com o .vue (que ainda tem watchers // próprios em 1C-ii). Expostos no return pra leitura/escrita externa. const _skipStatusWatch = ref(false); const _prevStatus = ref(null); const _restoringConvenio = ref(false); const samePatientConflict = ref(null); // ──────────────────────────────────────────────────────────────────── // 1. Watcher do form.status // Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit // `updateSeriesEvent` pro parent abrir o AgendaStatusChangeConfirmDialog // (com regras de exceção, saldo de pacote, etc). Sem confirm.require // aqui — o dialog do parent é a fonte canônica. // Pra remarcado mantém path antigo (confirm.require simples). // Se user cancelar o dialog: parent chama onReject pra reverter o form. // ──────────────────────────────────────────────────────────────────── watch( () => composer.form.value?.status, async (newVal, oldVal) => { if (_skipStatusWatch.value) return; if (!composer.isEdit.value || !composer.form.value?.id) return; const isStatusComDialog = ['realizado', 'faltou', 'cancelado'].includes(newVal); const isRemarcado = newVal === 'remarcado'; if (!isStatusComDialog && !isRemarcado) return; _prevStatus.value = oldVal; // Fase 5: emit pro parent abrir AgendaStatusChangeConfirmDialog. // Parent decide o que fazer e chama onReject() se user cancelar. if (isStatusComDialog) { const formId = composer.form.value.id; const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::')); emit('updateSeriesEvent', { id: isVirtual ? null : formId, status: newVal, recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10), inicio_em: composer.form.value.inicio_em, fim_em: composer.form.value.fim_em, is_virtual: isVirtual, // Form completo — handler usa pra resolver recurrence_id, billing_contract_id, etc row: { ...composer.form.value }, // Callback pra reverter status no form se user cancelar o dialog do parent. // _skipStatusWatch evita loop recursivo no watcher. onReject: () => { _skipStatusWatch.value = true; composer.form.value.status = _prevStatus.value; Promise.resolve().then(() => { _skipStatusWatch.value = false; }); } }); return; } // Path legacy pra 'remarcado': confirm.require simples + UPDATE direto. confirm.require({ header: 'Remarcar sessão', message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.', icon: 'pi pi-refresh', acceptLabel: 'Sim, confirmar', rejectLabel: 'Não', acceptSeverity: 'warn', accept: async () => { try { const formId = composer.form.value.id; const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::')); if (isVirtual) { emit('updateSeriesEvent', { id: null, status: newVal, recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10), inicio_em: composer.form.value.inicio_em, fim_em: composer.form.value.fim_em, is_virtual: true, row: { ...composer.form.value } }); toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 }); return; } const { data, error } = await tenantDb().from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single(); if (error) throw error; toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 }); emit('updated', data); } catch (e) { toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 }); composer.form.value.status = _prevStatus.value; } }, reject: () => { composer.form.value.status = _prevStatus.value; } }); } ); // ──────────────────────────────────────────────────────────────────── // 2. Watcher do billingType — quando troca tipo (gratuito/particular/ // convenio), limpa campos dos outros tipos pra não vazar valores. // ──────────────────────────────────────────────────────────────────── watch(composer.billingType, (val) => { if (val === 'gratuito') { commitmentItems.value = []; composer.form.value.price = 0; composer.form.value.insurance_plan_id = null; composer.form.value.insurance_guide_number = null; composer.form.value.insurance_value = null; if (selectedPlanService) selectedPlanService.value = null; } if (val === 'particular') { composer.form.value.insurance_plan_id = null; composer.form.value.insurance_guide_number = null; composer.form.value.insurance_value = null; if (selectedPlanService) selectedPlanService.value = null; } if (val === 'convenio') { commitmentItems.value = []; if (servicePickerSel) servicePickerSel.value = null; } }); // ──────────────────────────────────────────────────────────────────── // 3. Watcher [paciente_id, dia] — detecta se o paciente já tem outra // sessão no mesmo dia. Não bloqueia o save (só informa via UI). // ──────────────────────────────────────────────────────────────────── watch( () => [composer.form.value.paciente_id, composer.form.value.dia?.toString()], async () => { const pid = composer.form.value.paciente_id; samePatientConflict.value = null; if (!pid || !composer.isSessionEvent.value || !composer.visible.value) return; const d = composer.form.value.dia ? new Date(composer.form.value.dia) : new Date(); const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString(); let q = tenantDb().from('agenda_eventos') .select('id, inicio_em, fim_em, titulo') .eq('patient_id', pid) .gte('inicio_em', dayStart) .lt('inicio_em', dayEnd) .limit(1); if (composer.form.value.id) q = q.neq('id', composer.form.value.id); const { data } = await q.maybeSingle(); samePatientConflict.value = data || null; } ); // ──────────────────────────────────────────────────────────────────── // Helpers internos (puros) pra montar payload — extraídos pra serem // testáveis e reutilizáveis. Não dependem de refs reativos diretos, // recebem o form como argumento. // ──────────────────────────────────────────────────────────────────── function buildSavePayload({ form, requiresPatient, isSessionEvent, computedTitulo, inicioISO, fimISO }) { return { owner_id: form.owner_id, terapeuta_id: form.terapeuta_id, paciente_id: requiresPatient ? form.paciente_id : null, patient_id: requiresPatient ? form.paciente_id : null, tipo: EVENTO_TIPO_SESSAO, status: form.status || 'agendado', titulo: computedTitulo || null, modalidade: form.modalidade || null, observacoes: form.observacoes || null, inicio_em: inicioISO, fim_em: fimISO, determined_commitment_id: form.commitment_id || null, titulo_custom: form.titulo_custom || null, extra_fields: Object.keys(form.extra_fields || {}).length ? form.extra_fields : null, price: isSessionEvent ? (form.price ?? null) : null, insurance_plan_id: isSessionEvent ? (form.insurance_plan_id ?? null) : null, insurance_guide_number: isSessionEvent ? (form.insurance_guide_number ?? null) : null, insurance_value: isSessionEvent ? (form.insurance_value ?? null) : null, insurance_plan_service_id: isSessionEvent ? (form.insurance_plan_service_id ?? null) : null }; } function buildRecorrenciaPayload({ recorrenciaType, diaSemanaRecorrencia, diasSelecionados, startTime, duracaoMin, dataFimCalculada, qtdSessoesEfetiva, serieValorMode, commitmentItemsList, ocorrenciasComConflito }) { if (recorrenciaType === 'avulsa') return null; return { tipo: 'recorrente', tipoFreq: recorrenciaType, diaSemana: diaSemanaRecorrencia, diasSemana: diasSelecionados, horaInicio: startTime ? `${startTime}:00` : null, duracaoMin, dataFim: dataFimCalculada ? dataFimCalculada.toISOString() : null, qtdSessoes: qtdSessoesEfetiva, serieValorMode, commitmentItems: commitmentItemsList.slice(), conflitos: ocorrenciasComConflito .filter((o) => o.conflict) .map((o) => ({ date: o.date.toISOString().slice(0, 10), conflict: o.conflict })) }; } // ──────────────────────────────────────────────────────────────────── // 4. onSave — valida (canSave + timeConflict), monta payload e emite. // ──────────────────────────────────────────────────────────────────── function onSave() { if (!composer.canSave.value) return; if (composer.timeConflict.value) { toast.add({ severity: 'warn', summary: 'Conflito de horário', detail: `${composer.timeConflict.value}. Ajuste o horário ou a duração.`, life: 4500 }); return; } const inicioISO = composer.inicioDateTime.value?.toISOString() || null; const fimISO = composer.fimDateTime.value?.toISOString() || null; const payload = buildSavePayload({ form: composer.form.value, requiresPatient: composer.requiresPatient.value, isSessionEvent: composer.isSessionEvent.value, computedTitulo: composer.computedTitulo.value, inicioISO, fimISO }); // serieValorMode e similars não estão no composer (1B); são lidos // do .vue via props.eventActionsExtras se passados, ou null como // default. 1C-i: assumimos null se não fornecido pra simplificar. const recorrencia = composer.isSessionEvent.value ? buildRecorrenciaPayload({ recorrenciaType: composer.recorrenciaType.value, diaSemanaRecorrencia: composer.diaSemanaRecorrencia.value, diasSelecionados: composer.diasSelecionados.value, startTime: composer.form.value.startTime, duracaoMin: composer.form.value.duracaoMin, dataFimCalculada: composer.dataFimCalculada.value, qtdSessoesEfetiva: composer.qtdSessoesEfetiva.value, serieValorMode: 'multiplicar', // default; .vue pode passar outro via _serieValorMode commitmentItemsList: commitmentItems.value, ocorrenciasComConflito: composer.ocorrenciasComConflito.value }) : null; // Escopo de edição — só quando edita série existente const emitEditMode = composer.hasSerie.value ? composer.editScope.value : null; const emitRecurrenceId = composer.hasSerie.value ? props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null : null; const emitOriginalDate = composer.hasSerie.value ? props.eventRow?.original_date ?? null : null; emit('save', { id: composer.form.value.id, payload, recorrencia, editMode: emitEditMode, recurrence_id: emitRecurrenceId, original_date: emitOriginalDate, // _occurrenceMode: flag pra distinguir save do 2o dialog empilhado // (editar UMA ocorrencia) do save do dialog pai. Handler decide qual // dialog fechar — sem isso, fechava sempre o pai. 2026-05-12. _occurrenceMode: !!props.occurrenceMode, // chargeMode (Opção C1, 2026-05-13): handler decide entre criar // financial_record (avulsa+session), billing_contract (recorrente+package) // ou materializar N ocorrências + N records (recorrente+per_session). // UI no .vue garante valores válidos por modo. chargeMode: chargeMode?.value ?? 'none', // packageStyle (2026-05-14): handler em useMelissaAgenda usa pra // decidir entre upfront (1 record total + materializa 1ª) ou // saldo (só contrato). packageStyle: packageStyle?.value ?? 'upfront', // paymentMethod + markPaidNow (refatorado 2026-05-16): substituem // o antigo paymentSettlement. Handler aplica payment_method (sempre) // e status=paid+paid_at apenas quando markPaidNow=true && method!='link'. paymentMethod: paymentMethod?.value ?? 'link', markPaidNow: markPaidNow?.value === true, // legado — mantido para compatibilidade serie_id: props.eventRow?.serie_id ?? null, serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null, onSaved: composer.isSessionEvent.value ? async (eventId, { markCustomized = false } = {}) => { await saveCommitmentItems(eventId, commitmentItems.value, { markCustomized }); } : null }); } // ──────────────────────────────────────────────────────────────────── // 5. onDelete — avulsa: confirm simples + emit(id). // Série: confirm com escopo (somente_este/seguintes/todos) + emit({id, editMode}). // ──────────────────────────────────────────────────────────────────── function onDelete() { if (!composer.form.value.id) return; if (composer.hasSerie.value) { const isTodos = composer.editScope.value === '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.' : 'Esta sessão faz parte de uma série. O que deseja remover?', icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle', acceptClass: 'p-button-danger', acceptLabel: isTodos ? 'Sim, encerrar série' : composer.editScopeOptions.value.find((o) => o.value === composer.editScope.value)?.label || 'Excluir', rejectLabel: 'Cancelar', accept: () => emit('delete', { id: composer.form.value.id, editMode: composer.editScope.value, recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null, original_date: props.eventRow?.original_date ?? null, serie_id: props.eventRow?.serie_id ?? null }) }); return; } confirm.require({ header: 'Excluir compromisso', message: 'Tem certeza? Essa ação não pode ser desfeita.', icon: 'pi pi-exclamation-triangle', acceptClass: 'p-button-danger', acceptLabel: 'Excluir', rejectLabel: 'Cancelar', accept: () => emit('delete', composer.form.value.id) }); } // ──────────────────────────────────────────────────────────────────── // 6. onEncerrarSerie — confirm explícito de encerramento total da série. // Diferente do onDelete em 'todos' porque pode ser chamado direto // de um botão dedicado, sem depender de editScope. // ──────────────────────────────────────────────────────────────────── function onEncerrarSerie() { confirm.require({ header: 'Encerrar toda a série', message: 'Todos os agendamentos da série serão removidos permanentemente, incluindo exceções e recorrências. Esta sessão será mantida como avulsa. Esta ação é irreversível.', icon: 'pi pi-trash', acceptClass: 'p-button-danger', acceptLabel: 'Sim, encerrar série', rejectLabel: 'Cancelar', accept: () => emit('delete', { id: composer.form.value.id, editMode: 'todos', recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null, original_date: props.eventRow?.original_date ?? null, serie_id: props.eventRow?.serie_id ?? null }) }); } return { // refs internos (expostos pra .vue ler/escrever em watchers próprios) _skipStatusWatch, _prevStatus, _restoringConvenio, samePatientConflict, // helpers de payload (públicos pra teste isolado) buildSavePayload, buildRecorrenciaPayload, // handlers onSave, onDelete, onEncerrarSerie }; }