agenda Fase D: adapter Clinica usa agendaBilling.service

AgendaClinicaPage espelha Fase C: useAgendaStatusChange composable
+ AgendaStatusChangeConfirmDialog plugado.

onUpdateSeriesEvent reescrito:
- Materializa virtual se preciso (via createClinic com status='agendado'
  + tenantId)
- updateClinic({ status }) no DB
- applyStatusChange(eventoId, row, novoStatus) ramifica via dialog
  quando preciso
- loadClinicRange() refetch apos applied

Mesma feature parity de Melissa pra status change na Clinica:
multa, taxa cancelamento tardio, consumir saldo, gerar cobranca
pacote saldo, reverse transition trava — tudo via agendaBilling.service.

Fase C (Rail) + Fase D (Clinica) fechadas. Os 3 layouts (Melissa/
Rail/Clinica) agora compartilham o billing core do agendaBilling.
service via composable useAgendaStatusChange.

Pendente (residual incremental):
- Indicadores visuais (3 canais) nos 3 layouts
- Antecipar/Revogar pagamento no popover de Rail (Rail nao tem
  popover separado — usa AgendaEventDialog direto; precisa
  refactor maior)
- Doc de ajuda

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 09:48:18 -03:00
parent 034c2c0f3d
commit 6807b447cb
+66 -16
View File
@@ -36,6 +36,12 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
import { useFeriados } from '@/composables/useFeriados'; import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios'; import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// Fase D (replicação Clínica): adopta agendaBilling.service via composable
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
// pacote saldo/upfront (C7-C13 de Melissa, espelho da Fase C do Rail).
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers'; import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
@@ -502,6 +508,15 @@ const ownerOptions = computed(() => staffCols.value.map((p) => ({ label: p.title
// -------------------- events -------------------- // -------------------- events --------------------
const { loading: loadingEvents, error: eventsError, rows, loadClinicRange, createClinic, updateClinic, removeClinic } = useAgendaClinicEvents(); const { loading: loadingEvents, error: eventsError, rows, loadClinicRange, createClinic, updateClinic, removeClinic } = useAgendaClinicEvents();
// Fase D: status change com confirm dialog + billing (Melissa pattern).
const {
dialogOpen: statusDialogOpen,
dialogProps: statusDialogProps,
onDialogConfirm: onStatusDialogConfirm,
onDialogCancel: onStatusDialogCancel,
applyStatusChange
} = useAgendaStatusChange({ toast });
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence(); const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
const { saveRuleItems, propagateToSerie } = useCommitmentServices(); const { saveRuleItems, propagateToSerie } = useCommitmentServices();
@@ -1189,29 +1204,33 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) { async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
const tid = tenantId.value; const tid = tenantId.value;
try { try {
if (id) {
await updateClinic(id, { status }, { tenantId: tid });
return;
}
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);
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
if (existing?.id) {
await updateClinic(existing.id, { status }, { tenantId: tid });
} else {
const row = dialogEventRow.value || {}; const row = dialogEventRow.value || {};
await createClinic(
// 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;
} else {
// Materializa com status='agendado'; o status final aplica
// após applyStatusChange ramificar pelo dialog se preciso.
const created = await createClinic(
{ {
owner_id: dialogOwnerId.value || clinicOwnerId.value, owner_id: dialogOwnerId.value || clinicOwnerId.value,
tenant_id: tid, tenant_id: tid,
recurrence_id: rid, recurrence_id: rid,
recurrence_date: rDate, recurrence_date: rDate,
tipo: 'sessao', tipo: 'sessao',
status, status: 'agendado',
inicio_em, inicio_em,
fim_em, fim_em,
visibility_scope: 'public', visibility_scope: 'public',
@@ -1221,6 +1240,22 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
}, },
{ tenantId: tid } { tenantId: tid }
); );
eventoId = created?.id || null;
}
}
// 2) Atualiza status no DB
if (eventoId) {
await updateClinic(eventoId, { status }, { tenantId: tid });
}
// 3) Fluxo de billing (load context + dialog + apply)
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
// 4) Refetch se aplicou (UI reflete novo estado)
if (applied && typeof loadClinicRange === 'function') {
await loadClinicRange();
} }
} catch (e) { } catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 }); toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
@@ -2463,6 +2498,21 @@ function goRecorrencias() {
<!-- Dialog de Bloqueio --> <!-- Dialog de Bloqueio -->
<BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="clinicOwnerId" :tenantId="tenantId || ''" @saved="refetch" /> <BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="clinicOwnerId" :tenantId="tenantId || ''" @saved="refetch" />
<!-- Fase D: confirma status change com decisões de billing
(multa, consumir saldo, gerar cobrança, reverse transition). -->
<AgendaStatusChangeConfirmDialog
v-model="statusDialogOpen"
:evento="statusDialogProps.evento"
:novoStatus="statusDialogProps.novoStatus"
:regraExcecao="statusDialogProps.regraExcecao"
:billingContract="statusDialogProps.billingContract"
:billingContractStyle="statusDialogProps.billingContractStyle"
:pendingRecord="statusDialogProps.pendingRecord"
:sessionPrice="statusDialogProps.sessionPrice"
@confirm="onStatusDialogConfirm"
@update:modelValue="(v) => !v && onStatusDialogCancel()"
/>
<!-- Dialog: feriados próximos (todos os dias úteis — bloqueados e pendentes) --> <!-- Dialog: feriados próximos (todos os dias úteis — bloqueados e pendentes) -->
<Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }"> <Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">