agenda Fase C: adapter Rail usa agendaBilling.service
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||||
|
| <AgendaStatusChangeConfirmDialog
|
||||||
|
| v-model="dialogOpen"
|
||||||
|
| :evento="dialogProps.evento"
|
||||||
|
| :novoStatus="dialogProps.novoStatus"
|
||||||
|
| :regraExcecao="dialogProps.regraExcecao"
|
||||||
|
| :billingContract="dialogProps.billingContract"
|
||||||
|
| :billingContractStyle="dialogProps.billingContractStyle"
|
||||||
|
| :pendingRecord="dialogProps.pendingRecord"
|
||||||
|
| :sessionPrice="dialogProps.sessionPrice"
|
||||||
|
| @confirm="onDialogConfirm"
|
||||||
|
| @update:modelValue="(v) => !v && onDialogCancel()"
|
||||||
|
| />
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ 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 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';
|
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -119,6 +126,16 @@ watch(eventsLoading, (val) => {
|
|||||||
if (!val) eventsHasLoaded.value = true;
|
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 { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
|
||||||
|
|
||||||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||||||
@@ -1711,29 +1728,34 @@ 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 }) {
|
||||||
try {
|
try {
|
||||||
if (id) {
|
|
||||||
await update(id, { status });
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
if (existing?.id) {
|
|
||||||
await update(existing.id, { status });
|
|
||||||
} else {
|
|
||||||
const row = dialogEventRow.value || {};
|
const row = dialogEventRow.value || {};
|
||||||
await create({
|
|
||||||
|
// 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,
|
owner_id: ownerId.value,
|
||||||
tenant_id: clinicTenantId.value,
|
tenant_id: clinicTenantId.value,
|
||||||
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',
|
||||||
@@ -1742,6 +1764,24 @@ async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim
|
|||||||
determined_commitment_id: row.determined_commitment_id || null,
|
determined_commitment_id: row.determined_commitment_id || null,
|
||||||
price: row.price ?? null
|
price: row.price ?? null
|
||||||
});
|
});
|
||||||
|
eventoId = created?.id || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
|
||||||
|
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
|
||||||
|
if (applied) {
|
||||||
|
await loadMyRange?.();
|
||||||
}
|
}
|
||||||
} 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 });
|
||||||
@@ -3303,6 +3343,21 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Dialog: Prontuário -->
|
<!-- Dialog: Prontuário -->
|
||||||
<PatientProntuario :key="selectedPatient?.id || 'none'" v-model="prontuarioOpen" :patient="selectedPatient" @close="closeProntuario" />
|
<PatientProntuario :key="selectedPatient?.id || 'none'" v-model="prontuarioOpen" :patient="selectedPatient" @close="closeProntuario" />
|
||||||
|
|
||||||
|
<!-- Fase C: 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()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user