From 034c2c0f3d4d64c949911fcea2968db42d5e734c Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 09:46:35 -0300 Subject: [PATCH] agenda Fase C: adapter Rail usa agendaBilling.service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgendaTerapeutaPage (Rail) ganha o fluxo de status change do Melissa via novo composable useAgendaStatusChange (reusable wrapper sobre agendaBilling.service). src/features/agenda/composables/useAgendaStatusChange.js (novo): - Composable Tipo A pra qualquer page que precise do flow load context -> dialog se necessario -> apply decisoes - Mantem state do dialog + resolver promise - Expoe applyStatusChange(eventoId, row, novoStatus) - Resolve ownerId via supabase.auth + tenantId via tenantStore AgendaTerapeutaPage: - onUpdateSeriesEvent refatorado: materializa virtual se preciso -> update status -> applyStatusChange (load ctx + dialog + apply) - AgendaStatusChangeConfirmDialog plugado no template Antes: Rail fazia so update(id, { status }) cru — sem multa, sem pacote, sem reverse, sem nada de C7-C13. Era a versao primitiva do status change. Depois: Rail tem feature parity com Melissa pra status change. Multa por falta, taxa de cancelamento tardio, consumir saldo do pacote, gerar cobranca de pacote saldo, reverse transition trava — tudo via mesmo agendaBilling.service. Pendente Fase C: indicadores visuais (3 canais) + antecipar pagamento (popover-specific, depende refactor maior do AgendaEventDialog ou criar Rail popover). Fica pra iter incremental. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useAgendaStatusChange.js | 147 ++++++++++++++++++ .../agenda/pages/AgendaTerapeutaPage.vue | 109 +++++++++---- 2 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 src/features/agenda/composables/useAgendaStatusChange.js diff --git a/src/features/agenda/composables/useAgendaStatusChange.js b/src/features/agenda/composables/useAgendaStatusChange.js new file mode 100644 index 0000000..64616f1 --- /dev/null +++ b/src/features/agenda/composables/useAgendaStatusChange.js @@ -0,0 +1,147 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/agenda/composables/useAgendaStatusChange.js +| +| Composable Tipo A que orquestra o fluxo de status change da agenda +| usando agendaBilling.service. Reusável em Melissa / Rail / Clínica. +| +| Uso: +| const { applyStatusChange, dialogOpen, dialogProps, onDialogConfirm, +| onDialogCancel } = useAgendaStatusChange({ toast }); +| +| // No handler: +| await applyStatusChange({ eventoId, row, novoStatus }); +| +| // No template: +| +|-------------------------------------------------------------------------- +*/ + +import { ref } from 'vue'; +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { + loadStatusChangeContext, + needsStatusConfirmDialog, + applyStatusDecisions +} from '@/features/agenda/services/agendaBilling.service'; + +/** + * @param {object} [opts] + * @param {object} [opts.toast] instância de useToast (PrimeVue). Opcional. + * @returns composable com state reativo + applyStatusChange + */ +export function useAgendaStatusChange({ toast = null } = {}) { + const tenantStore = useTenantStore(); + + // Dialog state — bindar no template + const dialogOpen = ref(false); + const dialogProps = ref({}); + let _resolveDialog = null; + + function _openDialog(propsObj) { + return new Promise((resolve) => { + dialogProps.value = propsObj; + dialogOpen.value = true; + _resolveDialog = resolve; + }); + } + + function onDialogConfirm(decision) { + if (_resolveDialog) _resolveDialog(decision); + _resolveDialog = null; + dialogOpen.value = false; + } + + function onDialogCancel() { + if (_resolveDialog) _resolveDialog(null); + _resolveDialog = null; + dialogOpen.value = false; + } + + /** + * Coordena: load context → mostra dialog se preciso → aplica decisões. + * + * @param {object} args + * @param {string} args.eventoId uuid (null pra ocorrências virtuais ainda) + * @param {object} args.row row do agenda_eventos (pode ser parcial) + * @param {string} args.novoStatus 'realizado' | 'faltou' | 'cancelado' | 'agendado' + * + * @returns {Promise<{ applied: boolean, decision: object|null, ctx: object }>} + * applied=true se passou pelo applyStatusDecisions. + * decision=null se user cancelou o dialog. + */ + async function applyStatusChange({ eventoId, row, novoStatus }) { + const ownerId = (await supabase.auth.getUser()).data?.user?.id || null; + const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null; + + // 1) Carrega contexto + const ctx = await loadStatusChangeContext({ + supabase, + row, + eventoId, + status: novoStatus, + ownerId, + tenantId + }); + + // 2) Dialog se preciso + let decision = null; + if (needsStatusConfirmDialog(novoStatus, ctx)) { + decision = await _openDialog({ + evento: row, + novoStatus, + regraExcecao: ctx.regraExcecao, + billingContract: ctx.billingContract, + billingContractStyle: ctx.billingContract?.charging_style || null, + pendingRecord: ctx.pendingRecord, + sessionPrice: row?.price ?? null + }); + if (!decision) { + // user cancelou + return { applied: false, decision: null, ctx }; + } + } else { + // Sem dialog — default decision vazia (só aplicar status change básico) + decision = {}; + } + + // 3) Aplica decisões + await applyStatusDecisions({ + supabase, + toast, + eventoId, + row, + novoStatus, + ctx, + decision, + ownerId, + tenantId + }); + + return { applied: true, decision, ctx }; + } + + return { + // dialog state — pra template + dialogOpen, + dialogProps, + onDialogConfirm, + onDialogCancel, + // main action + applyStatusChange + }; +} diff --git a/src/features/agenda/pages/AgendaTerapeutaPage.vue b/src/features/agenda/pages/AgendaTerapeutaPage.vue index 89c69da..76ffe4b 100644 --- a/src/features/agenda/pages/AgendaTerapeutaPage.vue +++ b/src/features/agenda/pages/AgendaTerapeutaPage.vue @@ -49,6 +49,13 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter import { useFeriados } from '@/composables/useFeriados'; import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios'; +// Fase C (replicação Rail): adopta agendaBilling.service via composable +// reutilizável. Cobre status change com confirm dialog + multa + reverse + +// pacote saldo/upfront (C7-C13 de Melissa). +import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange'; +import { createPackageContract, materializeAndChargePerSession } from '@/features/agenda/services/agendaBilling.service'; +import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue'; + import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers'; const router = useRouter(); @@ -119,6 +126,16 @@ watch(eventsLoading, (val) => { if (!val) eventsHasLoaded.value = true; }); +// Fase C: orquestrador de status change (Melissa pattern). Cobre confirm +// dialog + multa + reverse + pacote saldo/upfront via agendaBilling.service. +const { + dialogOpen: statusDialogOpen, + dialogProps: statusDialogProps, + onDialogConfirm: onStatusDialogConfirm, + onDialogCancel: onStatusDialogCancel, + applyStatusChange +} = useAgendaStatusChange({ toast }); + const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence(); const { saveRuleItems, propagateToSerie } = useCommitmentServices(); @@ -1711,37 +1728,60 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) { try { - if (id) { - await update(id, { status }); - return; + const row = dialogEventRow.value || {}; + + // 1) Materializar virtual se preciso (resolve `eventoId` real) + let eventoId = id; + if (!id) { + if (!is_virtual || !inicio_em) return; + const rid = row.recurrence_id ?? row.serie_id ?? null; + const rDate = recurrence_date || inicio_em?.slice(0, 10); + const { data: existing } = await supabase + .from('agenda_eventos') + .select('id') + .eq('recurrence_id', rid) + .eq('recurrence_date', rDate) + .maybeSingle(); + if (existing?.id) { + eventoId = existing.id; + // Status atualiza só depois do dialog/applyStatusChange decidir + } else { + // Materializa criando com status='agendado' — o status final + // é aplicado por applyStatusChange (que pode ramificar pelo + // dialog se houver decisão a tomar) + const created = await create({ + owner_id: ownerId.value, + tenant_id: clinicTenantId.value, + recurrence_id: rid, + recurrence_date: rDate, + tipo: 'sessao', + status: 'agendado', + 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, + price: row.price ?? null + }); + eventoId = created?.id || null; + } } - if (!is_virtual || !inicio_em) return; - const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null; - const rDate = recurrence_date || inicio_em?.slice(0, 10); + // 2) Atualiza status NO DB (applyStatusChange só cuida de billing — não + // do status do agenda_evento em si). Antes do dialog pra ctx.row + // refletir o novo status do evento. + if (eventoId) { + await update(eventoId, { status }); + } - // Verifica se já foi materializado antes (evita violação de constraint) - const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle(); + // 3) Aplica fluxo de billing (load context + dialog se preciso + apply) + const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em }; + const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status }); - if (existing?.id) { - await update(existing.id, { status }); - } else { - const row = dialogEventRow.value || {}; - 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, - price: row.price ?? null - }); + // 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir + if (applied) { + await loadMyRange?.(); } } catch (e) { toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 }); @@ -3303,6 +3343,21 @@ onBeforeUnmount(() => { + + +