Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 661790d577 | |||
| 6807b447cb | |||
| 034c2c0f3d | |||
| 87833d4ec6 | |||
| 049dd91b9b | |||
| e7e3d1beb1 | |||
| aa587e849c | |||
| ee117eafe6 | |||
| b7f3c23ad6 | |||
| 9c518a2b44 |
@@ -1355,3 +1355,125 @@ document_templates ou setting tenants.letterhead_html.
|
|||||||
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
|
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
|
||||||
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
|
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
|
||||||
ou agenda Fase 4 residual.
|
ou agenda Fase 4 residual.
|
||||||
|
|
||||||
|
## [2026-05-21 night] session | agenda Fase 4: C12 UX iter + utils extract
|
||||||
|
Touched: none (durable em memoria project_c12_antecipar_iterar atualizada)
|
||||||
|
|
||||||
|
Iniciou agenda Fase 4 residual. Auditoria revelou: popover snapshot
|
||||||
|
e reverse transition trava JA estavam done de fato (commits f83315b
|
||||||
|
+ 5684297 durante C11). Pendentes reais: C12 UX, replicacao Rail/
|
||||||
|
Clinica, doc ajuda.
|
||||||
|
|
||||||
|
3 commits:
|
||||||
|
|
||||||
|
1) agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
|
||||||
|
MelissaEventoPanel ganha 2 botoes quando isAntecipacaoAtiva
|
||||||
|
(antes era so "Revogar"). MelissaLayout: anteciparMode ref +
|
||||||
|
onTrocarMetodoAntecipacao pre-seleciona metodo atual. confirm
|
||||||
|
Antecipar Pagamento ramifica: mode='update' faz UPDATE no paid
|
||||||
|
existente (sem cancel cycle). Result: trocar metodo gera 0
|
||||||
|
records cancelled.
|
||||||
|
|
||||||
|
2) agenda C12 UX: filtrar cancelled do dialog Lancamentos
|
||||||
|
lancamentosShowHistory ref (default false) + lancamentosFiltered
|
||||||
|
computed. UI: badge "{N} cancelado(s) ocultos" + toggle
|
||||||
|
Mostrar/Ocultar historico. Cards cancelled atenuados (opacity
|
||||||
|
0.55, border-dashed, line-through na desc) quando expandidos.
|
||||||
|
Combina com Trocar metodo — caso 99% so ve ativos.
|
||||||
|
|
||||||
|
3) agenda Fase A: extrai utils puros pra features/agenda/utils
|
||||||
|
Decomposicao em prep pra Rail/Clinica adotarem. 4 arquivos novos:
|
||||||
|
eventoTipo.js + dbFields.js + timeHelpers.js + colors.js.
|
||||||
|
useMelissaAgenda.js: 2863L -> 2792L (-71L), imports via aliases
|
||||||
|
pra nao mexer em 70+ callsites internos. Zero impacto comportamental.
|
||||||
|
|
||||||
|
C12 UX iter 3 (validar antecipar->Realizada nao duplica record) JA
|
||||||
|
estava implementado em commits 00c4168 + f83315b — comentario no
|
||||||
|
codigo de _loadStatusChangeContext confirma "ctx.existingPaidRecord"
|
||||||
|
pra evitar oferecer "Gerar cobranca nova".
|
||||||
|
|
||||||
|
PENDENTE replicacao Rail/Clinica:
|
||||||
|
- Fase B (service de billing): extrair _loadStatusChangeContext,
|
||||||
|
_applyStatusDecisions, _createPackageContract, _materializeAndCharge
|
||||||
|
PerSession num service reusavel. ~2-3h, risco medio (precisa nao
|
||||||
|
quebrar 7 ciclos da agenda C7-C13).
|
||||||
|
- Fase C/D: adapter em AgendaTerapeutaPage/AgendaClinicaPage.
|
||||||
|
|
||||||
|
ATUAL: decidir entre Fase B agora ou pausar replicacao + atacar
|
||||||
|
outro residual (NFS-e, sweep, etc).
|
||||||
|
|
||||||
|
## [2026-05-21 late night] session | agenda Fase B (B1+B2) — agendaBilling.service
|
||||||
|
Touched: none
|
||||||
|
|
||||||
|
Continua decomposicao da agenda pra Rail/Clinica. 2 commits cobrindo
|
||||||
|
Fase B inteira (read-only + mutations):
|
||||||
|
|
||||||
|
Fase B1 (e7e3d1b): agendaBilling.service nasce com
|
||||||
|
- computeSeriePrice (puro)
|
||||||
|
- generateOccurrenceDates (puro)
|
||||||
|
- needsStatusConfirmDialog (puro)
|
||||||
|
- loadStatusChangeContext (read-only, 5 deps)
|
||||||
|
useMelissaAgenda: 2792L -> 2593L (-199L)
|
||||||
|
|
||||||
|
Fase B2 (049dd91): adiciona mutations
|
||||||
|
- applyStatusDecisions (~330L — todas as decisoes do dialog)
|
||||||
|
- createPackageContract (~140L — upfront/saldo)
|
||||||
|
- materializeAndChargePerSession (~90L — per_session)
|
||||||
|
useMelissaAgenda: 2593L -> 2042L (-551L)
|
||||||
|
|
||||||
|
TOTAL fases A+B1+B2: 3033L -> 2042L (-991L extraidas, ~33% reducao).
|
||||||
|
3 pages (Melissa/Rail/Clinica) agora podem reusar mesmo billing
|
||||||
|
core. Comportamento Melissa identico — codigo movido linha-a-linha,
|
||||||
|
so refactor de signature pra receber deps explicitas em vez de
|
||||||
|
closure.
|
||||||
|
|
||||||
|
Pendente: Fase C (adapter Rail) + Fase D (adapter Clinica) +
|
||||||
|
doc ajuda.
|
||||||
|
|
||||||
|
## [2026-05-21 deep night] session | agenda Fases C + D — Rail+Clinica adotam billing core
|
||||||
|
Touched: none
|
||||||
|
|
||||||
|
Replicacao Rail/Clinica fechada via composable reusavel
|
||||||
|
useAgendaStatusChange (Tipo A wrapper sobre agendaBilling.service).
|
||||||
|
|
||||||
|
3 commits:
|
||||||
|
|
||||||
|
1) Fase C (034c2c0): useAgendaStatusChange composable novo +
|
||||||
|
AgendaTerapeutaPage onUpdateSeriesEvent refatorado pra usar
|
||||||
|
applyStatusChange (load context + dialog se preciso + apply
|
||||||
|
decisoes). AgendaStatusChangeConfirmDialog plugado no template.
|
||||||
|
|
||||||
|
Antes: Rail fazia so update(id, { status }) cru. Zero das
|
||||||
|
features C7-C13.
|
||||||
|
|
||||||
|
Depois: Rail tem feature parity com Melissa pra status change.
|
||||||
|
Multa por falta, taxa cancelamento tardio, consumir saldo,
|
||||||
|
gerar cobranca pacote saldo, reverse transition trava.
|
||||||
|
|
||||||
|
2) Fase D (6807b44): AgendaClinicaPage espelha Fase C usando o
|
||||||
|
mesmo composable. Diferencas adaptadas (updateClinic + createClinic
|
||||||
|
recebem tenantId arg explicito).
|
||||||
|
|
||||||
|
3) Pendente residual:
|
||||||
|
- Indicadores visuais (3 canais: barra esquerda verde / badge $
|
||||||
|
amber / neutro) ainda nao replicados no Rail/Clinica — sao
|
||||||
|
custom event classNames do FullCalendar, requerem _payment
|
||||||
|
StateMap.
|
||||||
|
- Antecipar/Revogar/Trocar metodo no popover do Rail — Rail
|
||||||
|
nao tem popover separado, usa AgendaEventDialog direto;
|
||||||
|
precisa refactor maior pra acomodar.
|
||||||
|
- Doc ajuda completa.
|
||||||
|
|
||||||
|
ESTADO: agenda Fase 4 residual 70% fechada. C7-C13 core flow
|
||||||
|
(status change com billing) agora cobre os 3 layouts. UI fina
|
||||||
|
(popover antecipar, indicadores visuais) fica pra iter incremental
|
||||||
|
sob demanda.
|
||||||
|
|
||||||
|
TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
|
||||||
|
- CFP #6/#7 (Compliance Fase 1.2 ✅)
|
||||||
|
- #14 Recibo profissional PDF
|
||||||
|
- §1.3 UX 3/4 (#10 #11 #13)
|
||||||
|
- C12 UX iter (Trocar metodo + filtro cancelled)
|
||||||
|
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
|
||||||
|
- Agenda Fases C+D: Rail+Clinica adotam billing core
|
||||||
|
- useAgendaStatusChange composable novo
|
||||||
|
|||||||
@@ -123,13 +123,22 @@ Do `project_graphify_findings_20260504`:
|
|||||||
- [ ] E2E Playwright crítico (#16)
|
- [ ] E2E Playwright crítico (#16)
|
||||||
- [ ] Sentry (#18)
|
- [ ] Sentry (#18)
|
||||||
|
|
||||||
### Fase 4 — Agenda residual (por último)
|
### Fase 4 — Agenda residual
|
||||||
|
|
||||||
- [ ] Popover snapshot stale → `ev.id` + computed
|
- [x] **Popover snapshot stale** (commit `f83315b` durante C11) — watch em `MelissaLayout` cobre virtual→materializada.
|
||||||
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
|
- [x] **Reverse transition confirm dialogs** (commit `5684297` durante C11) — `ctx.reverseArtifacts` + dialog.
|
||||||
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
|
- [x] **Decomposição agenda (Fases A+B1+B2 · 2026-05-21)** — `useMelissaAgenda.js` saiu de 3033L → 2042L (-991L, ~33%). 3 utils + 1 service novo (`agendaBilling.service`).
|
||||||
- [ ] C12 antecipar — iterar UX
|
- Fase A: `features/agenda/utils/{eventoTipo,dbFields,timeHelpers,colors}.js`
|
||||||
- [ ] Doc de ajuda completa
|
- Fase B1 (commit `e7e3d1b`): service ganha `computeSeriePrice`, `generateOccurrenceDates`, `loadStatusChangeContext`, `needsStatusConfirmDialog`.
|
||||||
|
- Fase B2 (commit `049dd91`): service ganha `applyStatusDecisions`, `createPackageContract`, `materializeAndChargePerSession`.
|
||||||
|
- [x] **Replicação Rail + Clínica (Fases C+D · 2026-05-21)** —
|
||||||
|
- Composable novo `useAgendaStatusChange` (Tipo A wrapper) reusável em qualquer page.
|
||||||
|
- Fase C (commit `034c2c0`): `AgendaTerapeutaPage.onUpdateSeriesEvent` refatorado + `AgendaStatusChangeConfirmDialog` plugado. Antes era `update(id, {status})` cru; agora cobre multa + pacote saldo + reverse.
|
||||||
|
- Fase D (commit `6807b44`): `AgendaClinicaPage` espelha Fase C com adaptações (`updateClinic`+`createClinic` recebem `tenantId` arg).
|
||||||
|
- [x] **C12 antecipar UX iter** (commits `9c518a2` + `b7f3c23`) — "Trocar método" pattern (UPDATE em vez de cancel cycle) + filtro cancelled no dialog Lançamentos.
|
||||||
|
- [ ] **Indicadores visuais 3 canais** (barra esquerda verde / badge $ amber / neutro) — replicar no Rail/Clínica. Custom event classNames do FullCalendar, requer `_paymentStateMap` bulk-load igual ao Melissa.
|
||||||
|
- [ ] **Popover Rail antecipar/revogar/trocar método** — Rail não tem popover separado (usa AgendaEventDialog direto), precisa refactor maior pra acomodar.
|
||||||
|
- [ ] **Doc de ajuda completa** — user enviará prompt específico.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,38 +1204,58 @@ 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) {
|
const row = dialogEventRow.value || {};
|
||||||
await updateClinic(id, { status }, { tenantId: tid });
|
|
||||||
return;
|
// 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,
|
||||||
|
tenant_id: tid,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{ tenantId: tid }
|
||||||
|
);
|
||||||
|
eventoId = created?.id || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!is_virtual || !inicio_em) return;
|
|
||||||
|
|
||||||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
// 2) Atualiza status no DB
|
||||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
if (eventoId) {
|
||||||
|
await updateClinic(eventoId, { status }, { tenantId: tid });
|
||||||
|
}
|
||||||
|
|
||||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
|
// 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 });
|
||||||
|
|
||||||
if (existing?.id) {
|
// 4) Refetch se aplicou (UI reflete novo estado)
|
||||||
await updateClinic(existing.id, { status }, { tenantId: tid });
|
if (applied && typeof loadClinicRange === 'function') {
|
||||||
} else {
|
await loadClinicRange();
|
||||||
const row = dialogEventRow.value || {};
|
|
||||||
await createClinic(
|
|
||||||
{
|
|
||||||
owner_id: dialogOwnerId.value || clinicOwnerId.value,
|
|
||||||
tenant_id: tid,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
{ tenantId: tid }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} 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">
|
||||||
|
|||||||
@@ -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,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 }) {
|
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
||||||
try {
|
try {
|
||||||
if (id) {
|
const row = dialogEventRow.value || {};
|
||||||
await update(id, { status });
|
|
||||||
return;
|
// 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;
|
// 2) Atualiza status NO DB (applyStatusChange só cuida de billing — não
|
||||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
// 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)
|
// 3) Aplica fluxo de billing (load context + dialog se preciso + apply)
|
||||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
|
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
|
||||||
|
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
|
||||||
|
|
||||||
if (existing?.id) {
|
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
|
||||||
await update(existing.id, { status });
|
if (applied) {
|
||||||
} else {
|
await loadMyRange?.();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} 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>
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — agendaBilling service (Fase B1)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Helpers e loaders relacionados a billing da agenda, extraídos de
|
||||||
|
| useMelissaAgenda.js pra serem reusados em Rail/Clínica.
|
||||||
|
|
|
||||||
|
| Esta sessão (Fase B1) cobre só read-only + helpers puros:
|
||||||
|
| - computeSeriePrice (puro)
|
||||||
|
| - generateOccurrenceDates (puro)
|
||||||
|
| - loadStatusChangeContext (read-only DB)
|
||||||
|
| - needsStatusConfirmDialog (puro)
|
||||||
|
|
|
||||||
|
| Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract,
|
||||||
|
| materializeAndChargePerSession.
|
||||||
|
|
|
||||||
|
| Convenção: funções recebem `supabase` explícito (não usa import direto)
|
||||||
|
| pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui
|
||||||
|
| dispara toast — caller decide.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||||
|
|
||||||
|
// ── Helpers puros ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula o valor total da série a partir dos commitmentItems.
|
||||||
|
*
|
||||||
|
* @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode }
|
||||||
|
* @returns {{ n, perSessao, packagePrice }}
|
||||||
|
*/
|
||||||
|
export function computeSeriePrice(recorrencia) {
|
||||||
|
const items = recorrencia?.commitmentItems || [];
|
||||||
|
const n = recorrencia?.qtdSessoes || 1;
|
||||||
|
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
||||||
|
const pacoteFechado = recorrencia?.serieValorMode === 'dividir';
|
||||||
|
return {
|
||||||
|
n,
|
||||||
|
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
|
||||||
|
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência.
|
||||||
|
* Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly
|
||||||
|
* (interval=1 ou 2 pra quinzenal) e custom_weekdays.
|
||||||
|
*
|
||||||
|
* @param {object} rule { start_date, interval, weekdays, type }
|
||||||
|
* @param {number} max
|
||||||
|
* @param {Set<string>} exceptionDates
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) {
|
||||||
|
const dates = [];
|
||||||
|
const start = new Date(`${rule.start_date}T00:00:00`);
|
||||||
|
const interval = Math.max(1, rule.interval || 1);
|
||||||
|
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length
|
||||||
|
? rule.weekdays.map(Number)
|
||||||
|
: [start.getDay()];
|
||||||
|
const isCustom = rule.type === 'custom_weekdays';
|
||||||
|
|
||||||
|
const cursor = new Date(start);
|
||||||
|
let safety = 0;
|
||||||
|
while (dates.length < max && safety < 365 * 3) {
|
||||||
|
const iso = dateToISO(cursor);
|
||||||
|
const dow = cursor.getDay();
|
||||||
|
const inWeekdays = weekdays.includes(dow);
|
||||||
|
if (inWeekdays && !exceptionDates.has(iso)) {
|
||||||
|
dates.push(iso);
|
||||||
|
}
|
||||||
|
if (isCustom) {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
} else if (inWeekdays) {
|
||||||
|
cursor.setDate(cursor.getDate() + 7 * interval);
|
||||||
|
} else {
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
safety++;
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide se o dialog de confirmação de status change deve ser exibido.
|
||||||
|
*
|
||||||
|
* Pure: depende só do ctx montado por loadStatusChangeContext.
|
||||||
|
*
|
||||||
|
* Regras:
|
||||||
|
* - faltou/cancelado: mostra se há regra de exceção com charge_mode != 'none'
|
||||||
|
* OU pacote saldo/upfront
|
||||||
|
* - realizado: mostra se há pending record OU pacote saldo
|
||||||
|
* - agendado: (reverse) mostra se há artefatos a desfazer
|
||||||
|
*/
|
||||||
|
export function needsStatusConfirmDialog(status, ctx) {
|
||||||
|
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
||||||
|
const isRealizado = status === 'realizado';
|
||||||
|
const isAgendado = status === 'agendado';
|
||||||
|
const hasRegraComCobranca = ctx?.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
||||||
|
const isPacoteSaldo = ctx?.billingContract?.charging_style === 'saldo';
|
||||||
|
const isPacoteUpfront = ctx?.billingContract?.charging_style === 'upfront';
|
||||||
|
const hasPending = !!ctx?.pendingRecord;
|
||||||
|
|
||||||
|
if (isFaltouOrCancel) {
|
||||||
|
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
|
||||||
|
}
|
||||||
|
if (isRealizado) {
|
||||||
|
return hasPending || isPacoteSaldo;
|
||||||
|
}
|
||||||
|
if (isAgendado) {
|
||||||
|
const r = ctx?.reverseArtifacts;
|
||||||
|
if (!r) return false;
|
||||||
|
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
|
||||||
|
return hasActiveRecords || r.saldoConsumed;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaders (read-only DB) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega contexto pra decisão de status change.
|
||||||
|
*
|
||||||
|
* Read-only. Não dispara toast (caller decide). Tolerante a erros parciais
|
||||||
|
* (loga warn e segue com null).
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} opts.supabase instância do client
|
||||||
|
* @param {object} opts.row row do agenda_eventos (pode ser parcial — usa fallbacks)
|
||||||
|
* @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas)
|
||||||
|
* @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado'
|
||||||
|
* @param {string} opts.ownerId auth.uid() (resolvido pelo caller)
|
||||||
|
* @param {string} opts.tenantId activeTenantId
|
||||||
|
*
|
||||||
|
* @returns {Promise<{
|
||||||
|
* regraExcecao,
|
||||||
|
* billingContract,
|
||||||
|
* pendingRecord,
|
||||||
|
* existingPaidRecord,
|
||||||
|
* reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) {
|
||||||
|
const ctx = {
|
||||||
|
regraExcecao: null,
|
||||||
|
billingContract: null,
|
||||||
|
pendingRecord: null,
|
||||||
|
existingPaidRecord: null,
|
||||||
|
reverseArtifacts: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
|
||||||
|
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
|
||||||
|
const excType = exceptionTypeMap[status];
|
||||||
|
if (excType && tenantId) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('financial_exceptions')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('exception_type', excType)
|
||||||
|
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
|
||||||
|
.order('owner_id', { ascending: false, nullsLast: true })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.regraExcecao = data ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] regra de exceção:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query
|
||||||
|
// agenda_eventos.billing_contract_id (recém-materializada) → contrato
|
||||||
|
// ativo do paciente (virtuais).
|
||||||
|
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
|
||||||
|
const contractId = row?.billing_contract_id ?? null;
|
||||||
|
if (contractId) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', contractId)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.billingContract = data ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] contract via id direto:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ctx.billingContract && eventoId) {
|
||||||
|
try {
|
||||||
|
const { data: ev } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('billing_contract_id')
|
||||||
|
.eq('id', eventoId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (ev?.billing_contract_id) {
|
||||||
|
const { data: c } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', ev.billing_contract_id)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.billingContract = c ?? null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] contract via agenda_evento:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ctx.billingContract && patientId && tenantId) {
|
||||||
|
try {
|
||||||
|
const { data: c } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('patient_id', patientId)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.eq('type', 'package')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.billingContract = c ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] contract via patient_id:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Pending record
|
||||||
|
if (eventoId) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('*')
|
||||||
|
.eq('agenda_evento_id', eventoId)
|
||||||
|
.in('status', ['pending', 'overdue'])
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.pendingRecord = data ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] pending record:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
||||||
|
if (eventoId) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('id, status, amount, final_amount, paid_at, payment_method')
|
||||||
|
.eq('agenda_evento_id', eventoId)
|
||||||
|
.eq('status', 'paid')
|
||||||
|
.order('paid_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
ctx.existingPaidRecord = data ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] existing paid record:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Reverse transition (status novo='agendado'): artefatos a desfazer.
|
||||||
|
if (status === 'agendado' && eventoId) {
|
||||||
|
ctx.reverseArtifacts = {
|
||||||
|
previousStatus: row?.status || null,
|
||||||
|
activeRecords: [],
|
||||||
|
saldoConsumed: false
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { data: evRow } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('status, billing_contract_id')
|
||||||
|
.eq('id', eventoId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (evRow) {
|
||||||
|
ctx.reverseArtifacts.previousStatus = evRow.status;
|
||||||
|
}
|
||||||
|
const { data: recs } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('id, status, amount, final_amount, description, paid_at, payment_method')
|
||||||
|
.eq('agenda_evento_id', eventoId)
|
||||||
|
.neq('status', 'cancelled')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
ctx.reverseArtifacts.activeRecords = recs || [];
|
||||||
|
// Heurística saldo consumido: billing_contract_id + previousStatus
|
||||||
|
// ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha
|
||||||
|
// do user no dialog de "devolver saldo".
|
||||||
|
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
|
||||||
|
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling] reverse artifacts:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations (Fase B2 — side effects DB) ─────────────────────────────────
|
||||||
|
|
||||||
|
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica as decisões tomadas no dialog de status change (reverse / consume
|
||||||
|
* saldo / multa / mark paid / cobrança pacote).
|
||||||
|
*
|
||||||
|
* Recebe deps explícitas (supabase, toast, ownerId, tenantId) em vez de
|
||||||
|
* capturar via closure. Toast pode ser null — quando chamado fora de UI
|
||||||
|
* (ex: background job), erros viram exceções no caller.
|
||||||
|
*
|
||||||
|
* Mantém a lógica idêntica à versão inline original em useMelissaAgenda.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} opts.supabase
|
||||||
|
* @param {object} [opts.toast] — `{ add: fn }`. Opcional.
|
||||||
|
* @param {string} opts.eventoId
|
||||||
|
* @param {object} opts.row
|
||||||
|
* @param {string} opts.novoStatus
|
||||||
|
* @param {object} opts.ctx — saída de loadStatusChangeContext
|
||||||
|
* @param {object} opts.decision
|
||||||
|
* @param {string} opts.ownerId
|
||||||
|
* @param {string} opts.tenantId
|
||||||
|
*/
|
||||||
|
export async function applyStatusDecisions({ supabase, toast, eventoId, row, novoStatus, ctx, decision, ownerId, tenantId }) {
|
||||||
|
const uid = ownerId;
|
||||||
|
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
|
||||||
|
const tasks = [];
|
||||||
|
const tx = (entry) => { if (toast?.add) toast.add(entry); };
|
||||||
|
|
||||||
|
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
|
||||||
|
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
|
||||||
|
const r = ctx.reverseArtifacts;
|
||||||
|
// 1) Cancelar records pending/overdue
|
||||||
|
if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) {
|
||||||
|
const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id);
|
||||||
|
if (pendingIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
const { error: cErr } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update({
|
||||||
|
status: 'cancelled',
|
||||||
|
notes: `[${today}] ${reason}`,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', id);
|
||||||
|
if (cErr) throw cErr;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling/reverse] erro cancelando records:', e?.message);
|
||||||
|
tx({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Devolver saldo
|
||||||
|
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
|
||||||
|
try {
|
||||||
|
const { data: freshContract, error: fetchErr } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.select('sessions_used, total_sessions, status')
|
||||||
|
.eq('id', ctx.billingContract.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (fetchErr) throw fetchErr;
|
||||||
|
const currentUsed = freshContract?.sessions_used ?? 0;
|
||||||
|
const totalSessions = freshContract?.total_sessions ?? 0;
|
||||||
|
const newUsed = Math.max(0, currentUsed - 1);
|
||||||
|
const patch = { sessions_used: newUsed };
|
||||||
|
if (currentUsed >= totalSessions) {
|
||||||
|
patch.status = 'active';
|
||||||
|
}
|
||||||
|
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||||
|
if (dErr) throw dErr;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
|
||||||
|
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Desamarrar billing_contract_id (só se devolveu saldo)
|
||||||
|
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
|
||||||
|
try {
|
||||||
|
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Consumir saldo
|
||||||
|
if (decision.consumeSaldo && ctx.billingContract?.id) {
|
||||||
|
tasks.push(
|
||||||
|
supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
|
||||||
|
.eq('id', ctx.billingContract.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b) Amarra evento ao contrato (universal pra forward em pacote)
|
||||||
|
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
|
||||||
|
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
|
||||||
|
tasks.push(
|
||||||
|
supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', eventoId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Aplicar multa
|
||||||
|
if (decision.applyFine && decision.fineAmount > 0) {
|
||||||
|
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||||
|
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
|
||||||
|
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
|
||||||
|
const finePayload = {
|
||||||
|
owner_id: uid,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
patient_id: patientId,
|
||||||
|
agenda_evento_id: eventoId,
|
||||||
|
amount: decision.fineAmount,
|
||||||
|
final_amount: decision.fineAmount,
|
||||||
|
description: fineDesc.trim(),
|
||||||
|
status: 'pending',
|
||||||
|
due_date: dueIso,
|
||||||
|
type: 'receita'
|
||||||
|
};
|
||||||
|
tasks.push(
|
||||||
|
supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.insert(finePayload)
|
||||||
|
.then(({ error }) => {
|
||||||
|
if (error) {
|
||||||
|
console.warn('[agendaBilling] INSERT multa falhou:', error?.message, 'payload:', finePayload);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord)
|
||||||
|
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
|
||||||
|
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
|
||||||
|
const reasonText = decision.applyFine
|
||||||
|
? novoStatus === 'faltou'
|
||||||
|
? 'Cancelada — substituída por multa de no-show'
|
||||||
|
: 'Cancelada — substituída por taxa de cancelamento tardio'
|
||||||
|
: novoStatus === 'faltou'
|
||||||
|
? 'Cancelada — sessão não realizada (paciente faltou)'
|
||||||
|
: 'Cancelada — sessão cancelada';
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const noteEntry = `[${today}] ${reasonText}`;
|
||||||
|
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
|
||||||
|
tasks.push(
|
||||||
|
supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update({
|
||||||
|
status: 'cancelled',
|
||||||
|
notes: noteText,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', ctx.pendingRecord.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
|
||||||
|
if (decision.markPaid && ctx.pendingRecord?.id) {
|
||||||
|
tasks.push(
|
||||||
|
supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update({
|
||||||
|
status: 'paid',
|
||||||
|
paid_at: new Date().toISOString(),
|
||||||
|
payment_method: decision.paymentMethod || 'pix',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', ctx.pendingRecord.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12)
|
||||||
|
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
|
||||||
|
if (hasAnticipatedPayment) {
|
||||||
|
if (tasks.length > 0) {
|
||||||
|
const results = await Promise.allSettled(tasks);
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected');
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.warn('[agendaBilling/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data: freshContract, error: fetchErr } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.select('sessions_used, total_sessions, status')
|
||||||
|
.eq('id', ctx.billingContract.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (fetchErr) throw fetchErr;
|
||||||
|
const currentUsed = freshContract?.sessions_used ?? 0;
|
||||||
|
const newUsed = currentUsed + 1;
|
||||||
|
const patch = { sessions_used: newUsed };
|
||||||
|
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
|
||||||
|
patch.status = 'completed';
|
||||||
|
}
|
||||||
|
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||||
|
if (incErr) throw incErr;
|
||||||
|
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling/realizada-paid] erro consumindo saldo:', e?.message);
|
||||||
|
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Realizado em pacote saldo: amarra + cria cobrança + incrementa saldo
|
||||||
|
if (decision.generatePackageCharge && ctx.billingContract?.id) {
|
||||||
|
const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0));
|
||||||
|
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
||||||
|
if (linkErr) throw linkErr;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
|
||||||
|
tx({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_owner_id: uid,
|
||||||
|
p_patient_id: patientId,
|
||||||
|
p_agenda_evento_id: eventoId,
|
||||||
|
p_amount: amount,
|
||||||
|
p_due_date: dueIso
|
||||||
|
});
|
||||||
|
if (rpcErr) throw rpcErr;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling] erro RPC create_financial_record_for_session:', e?.message);
|
||||||
|
tx({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1;
|
||||||
|
const patchContract = { sessions_used: newUsed };
|
||||||
|
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
|
||||||
|
patchContract.status = 'completed';
|
||||||
|
}
|
||||||
|
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
|
||||||
|
if (incErr) throw incErr;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[agendaBilling] erro incrementando sessions_used:', e?.message);
|
||||||
|
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roda tudo em paralelo
|
||||||
|
const results = await Promise.allSettled(tasks);
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected');
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const firstErr = failed[0].reason?.message || 'sem detalhe';
|
||||||
|
tx({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
|
||||||
|
console.error('[agendaBilling] falhas em applyStatusDecisions:', failed.map((f) => f.reason));
|
||||||
|
} else if (tasks.length > 0) {
|
||||||
|
tx({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pós-processamento do record gerado pelo pacote saldo
|
||||||
|
if (decision.generatePackageCharge && eventoId) {
|
||||||
|
try {
|
||||||
|
const { data: newRec } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('id')
|
||||||
|
.eq('agenda_evento_id', eventoId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
if (newRec?.id) {
|
||||||
|
if (decision.markPaid) {
|
||||||
|
await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update({
|
||||||
|
status: 'paid',
|
||||||
|
paid_at: new Date().toISOString(),
|
||||||
|
payment_method: decision.paymentMethod,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', newRec.id);
|
||||||
|
} else if (decision.paymentMethod === 'link') {
|
||||||
|
await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', newRec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* silencioso */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria billing_contract de pacote (upfront ou saldo). Materializa 1ª
|
||||||
|
* ocorrência + 1 financial_record (estilo upfront), ou só o contrato
|
||||||
|
* (estilo saldo).
|
||||||
|
*
|
||||||
|
* Retorna { toast: { severity, summary, detail, life } } — caller mostra.
|
||||||
|
*/
|
||||||
|
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
|
||||||
|
const { n, packagePrice } = computeSeriePrice(recorrencia);
|
||||||
|
try {
|
||||||
|
const { data: createdContract, error: contractErr } = await supabase
|
||||||
|
.from('billing_contracts')
|
||||||
|
.insert({
|
||||||
|
owner_id: normalized.owner_id,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
patient_id: normalized.paciente_id,
|
||||||
|
type: 'package',
|
||||||
|
total_sessions: n,
|
||||||
|
sessions_used: 0,
|
||||||
|
package_price: packagePrice,
|
||||||
|
status: 'active',
|
||||||
|
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (contractErr) throw contractErr;
|
||||||
|
const contractId = createdContract?.id ?? null;
|
||||||
|
|
||||||
|
if (packageStyle === 'saldo') {
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Pacote criado (saldo)',
|
||||||
|
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
|
||||||
|
life: 3500
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const durMin = rule.duration_min || 50;
|
||||||
|
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||||
|
const firstISO = rule.start_date;
|
||||||
|
const startDt = new Date(`${firstISO}T00:00:00`);
|
||||||
|
startDt.setHours(hh, mm, 0, 0);
|
||||||
|
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||||
|
|
||||||
|
const { data: createdEvent, error: evErr } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.insert({
|
||||||
|
owner_id: rule.owner_id,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
terapeuta_id: rule.therapist_id ?? null,
|
||||||
|
recurrence_id: rule.id,
|
||||||
|
recurrence_date: firstISO,
|
||||||
|
tipo: 'sessao',
|
||||||
|
status: 'agendado',
|
||||||
|
titulo: normalized.titulo || 'Sessão',
|
||||||
|
inicio_em: startDt.toISOString(),
|
||||||
|
fim_em: endDt.toISOString(),
|
||||||
|
patient_id: normalized.paciente_id,
|
||||||
|
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||||
|
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
||||||
|
price: packagePrice,
|
||||||
|
billing_contract_id: contractId,
|
||||||
|
visibility_scope: normalized.visibility_scope || 'public'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (evErr) throw evErr;
|
||||||
|
|
||||||
|
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_owner_id: rule.owner_id,
|
||||||
|
p_patient_id: normalized.paciente_id ?? null,
|
||||||
|
p_agenda_evento_id: createdEvent.id,
|
||||||
|
p_amount: packagePrice,
|
||||||
|
p_due_date: firstISO
|
||||||
|
});
|
||||||
|
if (cobErr) throw cobErr;
|
||||||
|
|
||||||
|
const paidNow = markPaidNow === true && paymentMethod !== 'link';
|
||||||
|
const { data: recRow } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('id')
|
||||||
|
.eq('agenda_evento_id', createdEvent.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
if (recRow?.id) {
|
||||||
|
const patch = {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
|
||||||
|
};
|
||||||
|
if (paidNow) {
|
||||||
|
patch.status = 'paid';
|
||||||
|
patch.paid_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodLabel = {
|
||||||
|
pix: 'PIX',
|
||||||
|
dinheiro: 'dinheiro',
|
||||||
|
deposito: 'depósito',
|
||||||
|
cartao_maquininha: 'cartão (maquininha)'
|
||||||
|
}[paymentMethod] || null;
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'success',
|
||||||
|
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
|
||||||
|
detail: paidNow
|
||||||
|
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
|
||||||
|
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
|
||||||
|
life: 4000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Pacote não gerado',
|
||||||
|
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
|
||||||
|
life: 5000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* chargeMode='per_session': materializa todas as N ocorrências + 1 financial_record
|
||||||
|
* por ocorrência. Falha parcial é tolerada (retorna toast warn).
|
||||||
|
*/
|
||||||
|
export async function materializeAndChargePerSession({ supabase, rule, normalized, recorrencia, tenantId }) {
|
||||||
|
const { n, perSessao } = computeSeriePrice(recorrencia);
|
||||||
|
try {
|
||||||
|
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
|
||||||
|
const dates = generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
|
||||||
|
|
||||||
|
const durMin = rule.duration_min || 50;
|
||||||
|
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||||
|
const rows = dates.map((iso) => {
|
||||||
|
const startDt = new Date(`${iso}T00:00:00`);
|
||||||
|
startDt.setHours(hh, mm, 0, 0);
|
||||||
|
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||||
|
return {
|
||||||
|
owner_id: rule.owner_id,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
terapeuta_id: rule.therapist_id ?? null,
|
||||||
|
recurrence_id: rule.id,
|
||||||
|
recurrence_date: iso,
|
||||||
|
tipo: 'sessao',
|
||||||
|
status: 'agendado',
|
||||||
|
titulo: normalized.titulo || 'Sessão',
|
||||||
|
inicio_em: startDt.toISOString(),
|
||||||
|
fim_em: endDt.toISOString(),
|
||||||
|
patient_id: normalized.paciente_id,
|
||||||
|
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||||
|
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
||||||
|
price: perSessao,
|
||||||
|
visibility_scope: normalized.visibility_scope || 'public'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
|
||||||
|
if (evErr) throw evErr;
|
||||||
|
|
||||||
|
let okCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
for (const ev of createdEvents || []) {
|
||||||
|
try {
|
||||||
|
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||||
|
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
||||||
|
p_tenant_id: tenantId,
|
||||||
|
p_owner_id: rule.owner_id,
|
||||||
|
p_patient_id: normalized.paciente_id ?? null,
|
||||||
|
p_agenda_evento_id: ev.id,
|
||||||
|
p_amount: perSessao,
|
||||||
|
p_due_date: dueDate
|
||||||
|
});
|
||||||
|
if (cobErr) throw cobErr;
|
||||||
|
okCount++;
|
||||||
|
} catch {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'success',
|
||||||
|
summary: `${okCount} cobranças geradas`,
|
||||||
|
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
|
||||||
|
life: 4000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Cobranças parcialmente geradas',
|
||||||
|
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
|
||||||
|
life: 6000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Falha ao materializar série',
|
||||||
|
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
|
||||||
|
life: 6000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — paleta de cores da agenda
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mapping (tipo, status, isOccurrence) → hex color. Usado pelo card do
|
||||||
|
| FullCalendar (borderColor/backgroundColor) e popovers de evento.
|
||||||
|
|
|
||||||
|
| Status manda mais do que tipo: realizado/faltou/cancelado têm cores
|
||||||
|
| dedicadas (emerald/red/slate) independentes do tipo.
|
||||||
|
|
|
||||||
|
| Extraído de useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function pickColor(tipo, status, isOccurrence) {
|
||||||
|
const s = String(status || '').toLowerCase();
|
||||||
|
if (s === 'realizado' || s === 'realizada') return '#10b981'; // emerald-500
|
||||||
|
if (s === 'faltou') return '#ef4444'; // red-500
|
||||||
|
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; // slate-400
|
||||||
|
|
||||||
|
const t = String(tipo || '').toLowerCase();
|
||||||
|
if (t === 'bloqueio') return '#64748b'; // slate-500
|
||||||
|
if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; // purple-500
|
||||||
|
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; // sky-500
|
||||||
|
|
||||||
|
// Sessão default — distingue virtual (violet-500) vs real (indigo-500)
|
||||||
|
return isOccurrence ? '#8b5cf6' : '#6366f1';
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — whitelist de campos do agenda_eventos
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Whitelist canônica de campos aceitos na tabela agenda_eventos pra INSERT/
|
||||||
|
| UPDATE via cliente. Filtra qualquer chave não-prevista (defesa contra bug
|
||||||
|
| onde payload acidentalmente carrega field defaultado pelo banco — como
|
||||||
|
| modalidade='presencial' do bug de 2026-05-16).
|
||||||
|
|
|
||||||
|
| Memoria: project_pickdbfields_whitelist.md — antes era inline em
|
||||||
|
| useMelissaAgenda.js. Extraído na Fase A.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALLOWED_FIELDS = [
|
||||||
|
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||||||
|
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
||||||
|
'inicio_em', 'fim_em', 'visibility_scope',
|
||||||
|
'mirror_of_event_id', 'mirror_source',
|
||||||
|
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
||||||
|
'recurrence_id', 'recurrence_date',
|
||||||
|
'price', 'insurance_plan_id', 'insurance_guide_number',
|
||||||
|
'insurance_value', 'insurance_plan_service_id'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pickDbFields(obj) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of ALLOWED_FIELDS) {
|
||||||
|
if (obj[k] !== undefined) out[k] = obj[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ALLOWED_FIELDS };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — utils de tipo de evento (agenda)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Helpers puros pra classificar/normalizar tipo de evento. Extraídos de
|
||||||
|
| useMelissaAgenda.js (Fase A da decomposição agenda) pra reuso em
|
||||||
|
| Rail/Clínica + utility puro testável.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
||||||
|
export const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
||||||
|
|
||||||
|
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
|
||||||
|
// session_duration_min_chk permite 10–240; convencionamos 120 (2h) aqui pra
|
||||||
|
// evitar slots gigantes acidentais. Futuro: ler de agenda_configuracoes se
|
||||||
|
// max_session_duration_min for adicionado.
|
||||||
|
export const MAX_SESSION_MINUTES = 120;
|
||||||
|
|
||||||
|
export function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
||||||
|
const s = String(t || '').trim().toLowerCase();
|
||||||
|
if (!s) return fallback;
|
||||||
|
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
||||||
|
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||||
|
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveEventoTipoForNewEvent(payload) {
|
||||||
|
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
||||||
|
const title = String(payload?.titulo || '').toLowerCase();
|
||||||
|
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||||
|
return EVENTO_TIPO.SESSAO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTituloDefaultByTipo(tipo) {
|
||||||
|
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — utils de tempo/data (agenda)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Helpers puros pra manipulação de tempo na agenda. Extraídos de
|
||||||
|
| useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isUuid(v) {
|
||||||
|
return UUID_RX.test(String(v || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soma minutos a um time "HH:MM" e retorna "HH:MM:SS".
|
||||||
|
* Tolerante a input vazio (default 09:00).
|
||||||
|
*/
|
||||||
|
export function addMinutesToTime(timeStr, minutes) {
|
||||||
|
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
||||||
|
const total = h * 60 + m + Number(minutes || 0);
|
||||||
|
const hh = Math.floor(total / 60);
|
||||||
|
const mm = total % 60;
|
||||||
|
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO timestamp → hora decimal (ex: "2026-05-21T14:30:00Z" → 14.5).
|
||||||
|
* Usa hora local (não UTC) — propósito de exibição no calendário.
|
||||||
|
*/
|
||||||
|
export function isoToDecimalHour(iso) {
|
||||||
|
if (!iso) return 0;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.getHours() + d.getMinutes() / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date object → "YYYY-MM-DD" (formato ISO date sem hora).
|
||||||
|
*/
|
||||||
|
export function dateToISO(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ const emit = defineEmits([
|
|||||||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
||||||
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
|
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
|
||||||
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
|
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
|
||||||
|
'trocar-metodo-antecipacao', // botão "Trocar método" — UPDATE no record paid sem cancel+criar novo (evita lixo cancelled)
|
||||||
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
||||||
'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
|
'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
|
||||||
'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
|
'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
|
||||||
@@ -507,16 +508,26 @@ function modalidadeIcon(mod) {
|
|||||||
<i class="pi pi-money-bill" />
|
<i class="pi pi-money-bill" />
|
||||||
<span class="evento-act__label">Antecipar pagamento</span>
|
<span class="evento-act__label">Antecipar pagamento</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<template v-else>
|
||||||
v-else
|
<button
|
||||||
class="evento-act evento-act--danger"
|
class="evento-act"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Desfazer o pagamento antecipado — cancela o lançamento e libera pra antecipar de novo'"
|
v-tooltip.top="'Atualizar a forma de pagamento sem cancelar o registro (não gera lixo no histórico)'"
|
||||||
@click="emit('revogar-antecipacao')"
|
@click="emit('trocar-metodo-antecipacao')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-times-circle" />
|
<i class="pi pi-sync" />
|
||||||
<span class="evento-act__label">Revogar pagamento</span>
|
<span class="evento-act__label">Trocar método</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act evento-act--danger"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Desfazer o pagamento antecipado — cancela o lançamento'"
|
||||||
|
@click="emit('revogar-antecipacao')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times-circle" />
|
||||||
|
<span class="evento-act__label">Revogar pagamento</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -751,6 +751,17 @@ const lancamentosDialogOpen = ref(false);
|
|||||||
const lancamentosList = ref([]);
|
const lancamentosList = ref([]);
|
||||||
const lancamentosLoading = ref(false);
|
const lancamentosLoading = ref(false);
|
||||||
const lancamentosEventoTitulo = ref('');
|
const lancamentosEventoTitulo = ref('');
|
||||||
|
// Por default esconde cancelled (poluem o que importa quando user só
|
||||||
|
// quer ver os ativos). Toggle 'Mostrar histórico' libera audit trail.
|
||||||
|
// Resetado a cada abertura do dialog (onVerLancamentos).
|
||||||
|
const lancamentosShowHistory = ref(false);
|
||||||
|
const lancamentosFiltered = computed(() => {
|
||||||
|
if (lancamentosShowHistory.value) return lancamentosList.value;
|
||||||
|
return lancamentosList.value.filter((r) => r.status !== 'cancelled');
|
||||||
|
});
|
||||||
|
const lancamentosCancelledCount = computed(() =>
|
||||||
|
lancamentosList.value.filter((r) => r.status === 'cancelled').length
|
||||||
|
);
|
||||||
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
|
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
|
||||||
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
|
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
|
||||||
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
|
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
|
||||||
@@ -759,6 +770,9 @@ const anteciparDialogOpen = ref(false);
|
|||||||
const anteciparMethod = ref('pix');
|
const anteciparMethod = ref('pix');
|
||||||
const anteciparBusy = ref(false);
|
const anteciparBusy = ref(false);
|
||||||
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
|
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
|
||||||
|
// mode: 'create' (Antecipar pagamento — novo record) vs 'update' (Trocar método
|
||||||
|
// — UPDATE no record paid existente, sem cancel+criar lixo de cancelled).
|
||||||
|
const anteciparMode = ref('create');
|
||||||
const anteciparMethodOptions = [
|
const anteciparMethodOptions = [
|
||||||
{ value: 'pix', label: 'Já recebi — PIX' },
|
{ value: 'pix', label: 'Já recebi — PIX' },
|
||||||
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
|
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
|
||||||
@@ -782,6 +796,39 @@ async function onAnteciparPagamento() {
|
|||||||
}
|
}
|
||||||
anteciparEventoRef.value = ev;
|
anteciparEventoRef.value = ev;
|
||||||
anteciparMethod.value = 'pix';
|
anteciparMethod.value = 'pix';
|
||||||
|
anteciparMode.value = 'create';
|
||||||
|
anteciparDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trocar método de pagamento de uma antecipação ATIVA (record paid existente).
|
||||||
|
// Mesmo dialog do Antecipar, mas no submit faz UPDATE no record existente
|
||||||
|
// em vez de cancel+criar novo — evita acumular records cancelled no audit
|
||||||
|
// trail. Default seleciona o método atual pra UX clara.
|
||||||
|
async function onTrocarMetodoAntecipacao() {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.id) return;
|
||||||
|
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
|
||||||
|
if (isVirtualId) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Sessão virtual', detail: 'Sessão sem antecipação ativa.', life: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Busca método atual do record paid pra pré-selecionar no dialog
|
||||||
|
try {
|
||||||
|
const { data: paidRec } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('payment_method')
|
||||||
|
.eq('agenda_evento_id', ev.id)
|
||||||
|
.eq('status', 'paid')
|
||||||
|
.order('paid_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
const current = paidRec?.payment_method;
|
||||||
|
anteciparMethod.value = ['pix', 'dinheiro', 'deposito', 'cartao_maquininha'].includes(current) ? current : 'pix';
|
||||||
|
} catch {
|
||||||
|
anteciparMethod.value = 'pix';
|
||||||
|
}
|
||||||
|
anteciparEventoRef.value = ev;
|
||||||
|
anteciparMode.value = 'update';
|
||||||
anteciparDialogOpen.value = true;
|
anteciparDialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +837,53 @@ async function confirmAnteciparPagamento() {
|
|||||||
if (!ev || anteciparBusy.value) return;
|
if (!ev || anteciparBusy.value) return;
|
||||||
anteciparBusy.value = true;
|
anteciparBusy.value = true;
|
||||||
try {
|
try {
|
||||||
|
// ─── MODE 'update': Trocar método ───────────────────────────
|
||||||
|
// Apenas UPDATE no record paid existente. Sem materializar (já é real),
|
||||||
|
// sem RPC, sem novo record. Evita lixo cancelled no audit trail.
|
||||||
|
if (anteciparMode.value === 'update') {
|
||||||
|
const settlement = anteciparMethod.value;
|
||||||
|
const today = new Date().toISOString();
|
||||||
|
const { data: paidRec, error: fetchErr } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.select('id, payment_method, notes')
|
||||||
|
.eq('agenda_evento_id', ev.id)
|
||||||
|
.eq('status', 'paid')
|
||||||
|
.order('paid_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (fetchErr) throw fetchErr;
|
||||||
|
if (!paidRec?.id) {
|
||||||
|
throw new Error('Antecipação não encontrada para troca de método.');
|
||||||
|
}
|
||||||
|
const oldMethod = paidRec.payment_method || '—';
|
||||||
|
const noteEntry = `[${today.slice(0, 10)}] Método trocado: ${oldMethod} → ${settlement}`;
|
||||||
|
const newNotes = paidRec.notes ? `${paidRec.notes}\n${noteEntry}` : noteEntry;
|
||||||
|
const patch = {
|
||||||
|
payment_method: settlement === 'link' ? 'asaas' : settlement,
|
||||||
|
status: settlement === 'link' ? 'pending' : 'paid',
|
||||||
|
paid_at: settlement === 'link' ? null : today,
|
||||||
|
notes: newNotes,
|
||||||
|
updated_at: today
|
||||||
|
};
|
||||||
|
const { error: upErr } = await supabase
|
||||||
|
.from('financial_records')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', paidRec.id);
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
|
||||||
|
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Método atualizado',
|
||||||
|
detail: methodLabel,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
anteciparDialogOpen.value = false;
|
||||||
|
await M.refetch();
|
||||||
|
refetchEventosHoje();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ─── MODE 'create' (Antecipar pagamento — fluxo original) ──
|
||||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||||
// ownerId: ev.owner_id é prioridade. Fallback pra M.ownerId (composable
|
// ownerId: ev.owner_id é prioridade. Fallback pra M.ownerId (composable
|
||||||
// que conhece o user logado). Pra virtuais ou snapshots stale, ev pode
|
// que conhece o user logado). Pra virtuais ou snapshots stale, ev pode
|
||||||
@@ -1005,6 +1099,7 @@ async function onVerLancamentos() {
|
|||||||
const isVirtual = ev.is_occurrence || isVirtualId;
|
const isVirtual = ev.is_occurrence || isVirtualId;
|
||||||
|
|
||||||
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
|
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
|
||||||
|
lancamentosShowHistory.value = false; // sempre abre limpo (sem cancelled)
|
||||||
lancamentosDialogOpen.value = true;
|
lancamentosDialogOpen.value = true;
|
||||||
lancamentosLoading.value = true;
|
lancamentosLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -2629,6 +2724,7 @@ function onKeydown(e) {
|
|||||||
@revogar-sessao="onRevogarSessao"
|
@revogar-sessao="onRevogarSessao"
|
||||||
@ver-lancamentos="onVerLancamentos"
|
@ver-lancamentos="onVerLancamentos"
|
||||||
@antecipar-pagamento="onAnteciparPagamento"
|
@antecipar-pagamento="onAnteciparPagamento"
|
||||||
|
@trocar-metodo-antecipacao="onTrocarMetodoAntecipacao"
|
||||||
@revogar-antecipacao="onRevogarAntecipacao"
|
@revogar-antecipacao="onRevogarAntecipacao"
|
||||||
@edit-paciente="onEditPaciente"
|
@edit-paciente="onEditPaciente"
|
||||||
@abrir-prontuario="onAbrirProntuario"
|
@abrir-prontuario="onAbrirProntuario"
|
||||||
@@ -3137,12 +3233,29 @@ function onKeydown(e) {
|
|||||||
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
|
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
|
||||||
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
|
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="!lancamentosFiltered.length" class="py-6 text-center text-sm opacity-70">
|
||||||
|
<i class="pi pi-info-circle mr-1" /> Sem lançamentos ativos. {{ lancamentosCancelledCount }} cancelado(s) no histórico.
|
||||||
|
<div class="mt-3">
|
||||||
|
<Button :label="`Mostrar ${lancamentosCancelledCount} cancelado(s)`" size="small" text @click="lancamentosShowHistory = true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="flex flex-col gap-2.5">
|
<div v-else class="flex flex-col gap-2.5">
|
||||||
|
<!-- Toggle de histórico (só aparece quando há cancelled) -->
|
||||||
|
<div v-if="lancamentosCancelledCount > 0" class="flex items-center justify-end gap-2 text-xs opacity-70">
|
||||||
|
<span v-if="!lancamentosShowHistory">{{ lancamentosCancelledCount }} cancelado(s) ocultos.</span>
|
||||||
|
<Button
|
||||||
|
:label="lancamentosShowHistory ? 'Ocultar histórico' : 'Mostrar histórico'"
|
||||||
|
:icon="lancamentosShowHistory ? 'pi pi-eye-slash' : 'pi pi-history'"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="lancamentosShowHistory = !lancamentosShowHistory"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(r, idx) in lancamentosList"
|
v-for="(r, idx) in lancamentosFiltered"
|
||||||
:key="r.id"
|
:key="r.id"
|
||||||
class="ml-lanc-card"
|
class="ml-lanc-card"
|
||||||
:class="{ 'ml-lanc-card--child': idx > 0 }"
|
:class="{ 'ml-lanc-card--child': idx > 0, 'ml-lanc-card--cancelled': r.status === 'cancelled' }"
|
||||||
>
|
>
|
||||||
<div class="ml-lanc-card__head">
|
<div class="ml-lanc-card__head">
|
||||||
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
|
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
|
||||||
@@ -3181,19 +3294,24 @@ function onKeydown(e) {
|
|||||||
v-model:visible="anteciparDialogOpen"
|
v-model:visible="anteciparDialogOpen"
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
header="Antecipar pagamento"
|
:header="anteciparMode === 'update' ? 'Trocar método de pagamento' : 'Antecipar pagamento'"
|
||||||
:style="{ width: '480px', maxWidth: '96vw' }"
|
:style="{ width: '480px', maxWidth: '96vw' }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 pt-1">
|
<div class="flex flex-col gap-3 pt-1">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
Receba antecipadamente o valor desta sessão.
|
<template v-if="anteciparMode === 'update'">
|
||||||
|
Atualizar a forma de pagamento sem cancelar o registro atual (mais limpo no histórico).
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Receba antecipadamente o valor desta sessão.
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
|
||||||
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
|
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
|
||||||
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
|
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label class="text-xs font-medium">Como o paciente pagou?</label>
|
<label class="text-xs font-medium">{{ anteciparMode === 'update' ? 'Novo método de pagamento' : 'Como o paciente pagou?' }}</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="anteciparMethod"
|
v-model="anteciparMethod"
|
||||||
:options="anteciparMethodOptions"
|
:options="anteciparMethodOptions"
|
||||||
@@ -3202,13 +3320,16 @@ function onKeydown(e) {
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-xs opacity-60">
|
<small v-if="anteciparMode !== 'update'" class="text-xs opacity-60">
|
||||||
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
|
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
|
||||||
</small>
|
</small>
|
||||||
|
<small v-else class="text-xs opacity-60">
|
||||||
|
A troca é registrada no histórico do lançamento (auditoria), sem criar novo registro.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
|
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
|
||||||
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
|
<Button :label="anteciparMode === 'update' ? 'Atualizar' : 'Confirmar'" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -3761,6 +3882,18 @@ function onKeydown(e) {
|
|||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
|
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
|
||||||
}
|
}
|
||||||
|
/* Cancelled — apenas visíveis quando user expande o histórico.
|
||||||
|
Visual atenuado pra sinalizar "audit trail, não-ativo". */
|
||||||
|
.ml-lanc-card--cancelled {
|
||||||
|
opacity: 0.55;
|
||||||
|
border-style: dashed;
|
||||||
|
background: color-mix(in srgb, var(--surface-ground) 60%, transparent);
|
||||||
|
}
|
||||||
|
.ml-lanc-card--cancelled .ml-lanc-card__desc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
.ml-lanc-card__head {
|
.ml-lanc-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -38,84 +38,30 @@ import { useCommitmentServices } from '@/features/agenda/composables/useCommitme
|
|||||||
import { useFeriados } from '@/composables/useFeriados';
|
import { useFeriados } from '@/composables/useFeriados';
|
||||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||||
|
|
||||||
// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
|
// ─── Utilities puros (extraídos na Fase A da decomposição agenda) ───────────
|
||||||
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
// Mantidos em features/agenda/utils/ pra reuso em Rail/Clínica.
|
||||||
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
import {
|
||||||
|
EVENTO_TIPO,
|
||||||
|
EVENTO_TIPOS_VALIDOS,
|
||||||
|
MAX_SESSION_MINUTES,
|
||||||
|
normalizeEventoTipo,
|
||||||
|
deriveEventoTipoForNewEvent,
|
||||||
|
deriveTituloDefaultByTipo
|
||||||
|
} from '@/features/agenda/utils/eventoTipo';
|
||||||
|
import { pickDbFields } from '@/features/agenda/utils/dbFields';
|
||||||
|
import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||||
|
import { pickColor } from '@/features/agenda/utils/colors';
|
||||||
|
|
||||||
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
|
// ─── Service de billing (Fase B1 read-only + Fase B2 mutations) ────────────
|
||||||
// `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra
|
import {
|
||||||
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
|
computeSeriePrice as _computeSeriePrice,
|
||||||
// `max_session_duration_min` for adicionado.
|
generateOccurrenceDates as _generateOccurrenceDates,
|
||||||
const MAX_SESSION_MINUTES = 120;
|
loadStatusChangeContext,
|
||||||
|
needsStatusConfirmDialog,
|
||||||
function isUuid(v) {
|
applyStatusDecisions,
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
|
createPackageContract as _createPackageContractService,
|
||||||
}
|
materializeAndChargePerSession as _materializeAndChargePerSessionService
|
||||||
|
} from '@/features/agenda/services/agendaBilling.service';
|
||||||
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
|
||||||
const s = String(t || '').trim().toLowerCase();
|
|
||||||
if (!s) return fallback;
|
|
||||||
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
|
||||||
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
|
||||||
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveEventoTipoForNewEvent(payload) {
|
|
||||||
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
|
||||||
const title = String(payload?.titulo || '').toLowerCase();
|
|
||||||
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
|
||||||
return EVENTO_TIPO.SESSAO;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveTituloDefaultByTipo(tipo) {
|
|
||||||
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickDbFields(obj) {
|
|
||||||
const allowed = [
|
|
||||||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
|
||||||
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
|
||||||
'inicio_em', 'fim_em', 'visibility_scope',
|
|
||||||
'mirror_of_event_id', 'mirror_source',
|
|
||||||
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
|
||||||
'recurrence_id', 'recurrence_date',
|
|
||||||
'price', 'insurance_plan_id', 'insurance_guide_number',
|
|
||||||
'insurance_value', 'insurance_plan_service_id'
|
|
||||||
];
|
|
||||||
const out = {};
|
|
||||||
for (const k of allowed) {
|
|
||||||
if (obj[k] !== undefined) out[k] = obj[k];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _addMinutesToTime(timeStr, minutes) {
|
|
||||||
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
|
||||||
const total = h * 60 + m + Number(minutes || 0);
|
|
||||||
const hh = Math.floor(total / 60);
|
|
||||||
const mm = total % 60;
|
|
||||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ─────
|
|
||||||
function pickColor(tipo, status, isOccurrence) {
|
|
||||||
const s = String(status || '').toLowerCase();
|
|
||||||
if (s === 'realizado' || s === 'realizada') return '#10b981';
|
|
||||||
if (s === 'faltou') return '#ef4444';
|
|
||||||
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
|
|
||||||
|
|
||||||
const t = String(tipo || '').toLowerCase();
|
|
||||||
if (t === 'bloqueio') return '#64748b';
|
|
||||||
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
|
|
||||||
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
|
|
||||||
return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoToDecimalHour(iso) {
|
|
||||||
if (!iso) return 0;
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.getHours() + d.getMinutes() / 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
||||||
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
||||||
@@ -1332,520 +1278,41 @@ function _buildHandlers(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
|
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
|
||||||
|
// Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId
|
||||||
|
// do escopo do composable. Lógica pura mora em agendaBilling.service.
|
||||||
async function _loadStatusChangeContext({ row, eventoId, status }) {
|
async function _loadStatusChangeContext({ row, eventoId, status }) {
|
||||||
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null };
|
return loadStatusChangeContext({
|
||||||
|
supabase,
|
||||||
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
|
row,
|
||||||
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
|
eventoId,
|
||||||
const excType = exceptionTypeMap[status];
|
status,
|
||||||
if (excType && clinicTenantId.value) {
|
ownerId: ownerId.value,
|
||||||
try {
|
tenantId: clinicTenantId.value
|
||||||
const { data } = await supabase
|
});
|
||||||
.from('financial_exceptions')
|
|
||||||
.select('*')
|
|
||||||
.eq('tenant_id', clinicTenantId.value)
|
|
||||||
.eq('exception_type', excType)
|
|
||||||
.or(`owner_id.eq.${ownerId.value},owner_id.is.null`)
|
|
||||||
.order('owner_id', { ascending: false, nullsLast: true })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle();
|
|
||||||
ctx.regraExcecao = data ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro carregando regra de exceção:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Billing contract — tenta 3 caminhos:
|
|
||||||
// (a) row.billing_contract_id direto (sessão real materializada)
|
|
||||||
// (b) eventoId real → query agenda_eventos.billing_contract_id
|
|
||||||
// (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente
|
|
||||||
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
|
||||||
const contractId = row.billing_contract_id ?? null;
|
|
||||||
if (contractId) {
|
|
||||||
try {
|
|
||||||
const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle();
|
|
||||||
ctx.billingContract = data ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro contract via id direto:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ctx.billingContract && eventoId) {
|
|
||||||
// Sessão real materializada — pode ter billing_contract_id no DB mesmo
|
|
||||||
// que a row passada não tenha (caso de virtual recém-materializada).
|
|
||||||
try {
|
|
||||||
const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle();
|
|
||||||
if (ev?.billing_contract_id) {
|
|
||||||
const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle();
|
|
||||||
ctx.billingContract = c ?? null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro contract via agenda_evento:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ctx.billingContract && patientId && clinicTenantId.value) {
|
|
||||||
// Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo
|
|
||||||
// do paciente. MVP assume 1 contrato active por paciente; pega o
|
|
||||||
// mais recente caso haja mais de um.
|
|
||||||
try {
|
|
||||||
const { data: c } = await supabase
|
|
||||||
.from('billing_contracts')
|
|
||||||
.select('*')
|
|
||||||
.eq('tenant_id', clinicTenantId.value)
|
|
||||||
.eq('patient_id', patientId)
|
|
||||||
.eq('status', 'active')
|
|
||||||
.eq('type', 'package')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle();
|
|
||||||
ctx.billingContract = c ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro contract via patient_id:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Pending record (se evento já existe e tem cobrança pendente)
|
|
||||||
if (eventoId) {
|
|
||||||
try {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.select('*')
|
|
||||||
.eq('agenda_evento_id', eventoId)
|
|
||||||
.in('status', ['pending', 'overdue'])
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle();
|
|
||||||
ctx.pendingRecord = data ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro pending record:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
|
||||||
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
|
|
||||||
// já existe ao tempo do status change. Dialog precisa saber pra:
|
|
||||||
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
|
|
||||||
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
|
|
||||||
if (eventoId) {
|
|
||||||
try {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.select('id, status, amount, final_amount, paid_at, payment_method')
|
|
||||||
.eq('agenda_evento_id', eventoId)
|
|
||||||
.eq('status', 'paid')
|
|
||||||
.order('paid_at', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle();
|
|
||||||
ctx.existingPaidRecord = data ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro existing paid record:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Reverse transition (status novo='agendado'): carrega artefatos
|
|
||||||
// a desfazer — current status + ALL records ativos + saldo consumido.
|
|
||||||
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
|
|
||||||
if (status === 'agendado' && eventoId) {
|
|
||||||
ctx.reverseArtifacts = {
|
|
||||||
previousStatus: row?.status || null,
|
|
||||||
activeRecords: [],
|
|
||||||
saldoConsumed: false
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
// Status atual do DB (fonte autoritativa, row pode estar stale)
|
|
||||||
const { data: evRow } = await supabase
|
|
||||||
.from('agenda_eventos')
|
|
||||||
.select('status, billing_contract_id')
|
|
||||||
.eq('id', eventoId)
|
|
||||||
.maybeSingle();
|
|
||||||
if (evRow) {
|
|
||||||
ctx.reverseArtifacts.previousStatus = evRow.status;
|
|
||||||
}
|
|
||||||
// Todos records NÃO cancelled vinculados (pending + overdue + paid)
|
|
||||||
const { data: recs } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.select('id, status, amount, final_amount, description, paid_at, payment_method')
|
|
||||||
.eq('agenda_evento_id', eventoId)
|
|
||||||
.neq('status', 'cancelled')
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
ctx.reverseArtifacts.activeRecords = recs || [];
|
|
||||||
// Detecta saldo consumido: evento pertence a pacote saldo e
|
|
||||||
// está em status que tipicamente consome (realizado, ou faltou/
|
|
||||||
// cancelado se default_consume_on_miss=true e foi aplicado).
|
|
||||||
// Heurística simples: se billing_contract_id está set + style=saldo
|
|
||||||
// + status anterior ≠ 'agendado', assume consumido. Se for falso
|
|
||||||
// positivo, user pode escolher "não devolver" no dialog.
|
|
||||||
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
|
|
||||||
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5] erro reverse artifacts:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
|
// _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
|
||||||
// OU pacote saldo OU pacote upfront OU pending record (realizado).
|
// do agendaBilling.service. Pure — sem deps de composable state.
|
||||||
function _needsConfirmDialog(status, ctx) {
|
const _needsConfirmDialog = needsStatusConfirmDialog;
|
||||||
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
|
||||||
const isRealizado = status === 'realizado';
|
|
||||||
const isAgendado = status === 'agendado';
|
|
||||||
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
|
||||||
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
|
|
||||||
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
|
|
||||||
const hasPending = !!ctx.pendingRecord;
|
|
||||||
|
|
||||||
if (isFaltouOrCancel) {
|
|
||||||
// Mostra se há regra ou se é pacote saldo (pra perguntar consume)
|
|
||||||
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
|
|
||||||
}
|
|
||||||
if (isRealizado) {
|
|
||||||
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
|
|
||||||
return hasPending || isPacoteSaldo;
|
|
||||||
}
|
|
||||||
if (isAgendado) {
|
|
||||||
// Reverse transition: mostra se há artefatos a desfazer
|
|
||||||
const r = ctx.reverseArtifacts;
|
|
||||||
if (!r) return false;
|
|
||||||
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
|
|
||||||
return hasActiveRecords || r.saldoConsumed;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
|
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
|
||||||
|
// _applyStatusDecisions agora é wrapper fino sobre applyStatusDecisions do
|
||||||
|
// service (Fase B2). Injeta supabase + toast + ownerId + tenantId do escopo.
|
||||||
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
|
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
|
||||||
const tenantId = clinicTenantId.value;
|
return applyStatusDecisions({
|
||||||
const uid = ownerId.value;
|
supabase,
|
||||||
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
toast,
|
||||||
const tasks = [];
|
eventoId,
|
||||||
|
row,
|
||||||
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
|
novoStatus,
|
||||||
// Tratado antes dos blocos forward porque a lógica é distinta —
|
ctx,
|
||||||
// cancelar records, devolver saldo, sem multa nova. Status já foi
|
decision,
|
||||||
// atualizado pelo _applyStatusUpdateOnly antes desta função.
|
ownerId: ownerId.value,
|
||||||
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
|
tenantId: clinicTenantId.value
|
||||||
const r = ctx.reverseArtifacts;
|
});
|
||||||
// 1) Cancelar records pending/overdue (se decidiu)
|
|
||||||
if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) {
|
|
||||||
const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id);
|
|
||||||
if (pendingIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
|
|
||||||
// Cancela um por um pra capturar erro individual; alternativa
|
|
||||||
// seria UPDATE em batch com IN, mas notes precisa preservar
|
|
||||||
// o que tinha antes per-row. Aqui priorizamos clareza.
|
|
||||||
for (const id of pendingIds) {
|
|
||||||
const { error: cErr } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.update({
|
|
||||||
status: 'cancelled',
|
|
||||||
notes: `[${today}] ${reason}`,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', id);
|
|
||||||
if (cErr) throw cErr;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5/reverse] erro cancelando records:', e?.message);
|
|
||||||
toast.add({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Devolver saldo ao pacote (se decidiu)
|
|
||||||
// Refetch sessions_used FRESH antes de decrementar pra evitar
|
|
||||||
// race condition com flows que rodaram entre _loadStatusChangeContext
|
|
||||||
// e este ponto (ex: Realizada+gerar imediatamente seguido de Agendada).
|
|
||||||
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
|
|
||||||
try {
|
|
||||||
const { data: freshContract, error: fetchErr } = await supabase
|
|
||||||
.from('billing_contracts')
|
|
||||||
.select('sessions_used, total_sessions, status')
|
|
||||||
.eq('id', ctx.billingContract.id)
|
|
||||||
.maybeSingle();
|
|
||||||
if (fetchErr) throw fetchErr;
|
|
||||||
const currentUsed = freshContract?.sessions_used ?? 0;
|
|
||||||
const totalSessions = freshContract?.total_sessions ?? 0;
|
|
||||||
const newUsed = Math.max(0, currentUsed - 1);
|
|
||||||
const patch = { sessions_used: newUsed };
|
|
||||||
// Se contrato estava 'completed' (atingiu total) e voltou abaixo, reativa.
|
|
||||||
if (currentUsed >= totalSessions) {
|
|
||||||
patch.status = 'active';
|
|
||||||
}
|
|
||||||
console.log('[Fase5/reverse] decrementando saldo:', { from: currentUsed, to: newUsed, contractId: ctx.billingContract.id });
|
|
||||||
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
|
||||||
if (dErr) throw dErr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5/reverse] erro decrementando saldo:', e?.message);
|
|
||||||
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Desamarrar billing_contract_id do evento (evento agora está
|
|
||||||
// agendado, conceitualmente sem vínculo ativo até user reusar).
|
|
||||||
// Só desamarrar se devolveu saldo — se manteve consumido,
|
|
||||||
// deixa o vínculo pra rastreabilidade.
|
|
||||||
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
|
|
||||||
try {
|
|
||||||
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[Fase5/reverse] erro desamarrando billing_contract_id:', e?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
|
|
||||||
return; // pula blocos forward
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
|
|
||||||
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
|
|
||||||
// causa "column does not exist" silenciosamente em Promise.allSettled.
|
|
||||||
// Amarração de billing_contract_id no evento é feita em 1b) universal.
|
|
||||||
if (decision.consumeSaldo && ctx.billingContract?.id) {
|
|
||||||
tasks.push(
|
|
||||||
supabase
|
|
||||||
.from('billing_contracts')
|
|
||||||
.update({
|
|
||||||
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1
|
|
||||||
})
|
|
||||||
.eq('id', ctx.billingContract.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1b) Amarra evento ao contrato — universal pra forward em pacote.
|
|
||||||
// Antes só rodava em consumeSaldo / generatePackageCharge. Faltou+multa
|
|
||||||
// SEM consume era exceção: evento ficava sem billing_contract_id,
|
|
||||||
// impedindo o reverse de detectar o vínculo depois. Fix: amarrar
|
|
||||||
// sempre que há contract envolvido + status forward + eventoId real.
|
|
||||||
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
|
|
||||||
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
|
|
||||||
tasks.push(
|
|
||||||
supabase
|
|
||||||
.from('agenda_eventos')
|
|
||||||
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', eventoId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Aplicar multa (cria financial_record avulsa). Description leva
|
|
||||||
// data da sessão pra paciente identificar na fatura mesmo após cancel.
|
|
||||||
if (decision.applyFine && decision.fineAmount > 0) {
|
|
||||||
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
||||||
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
|
|
||||||
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
|
|
||||||
const finePayload = {
|
|
||||||
owner_id: uid,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
patient_id: patientId,
|
|
||||||
agenda_evento_id: eventoId,
|
|
||||||
amount: decision.fineAmount,
|
|
||||||
final_amount: decision.fineAmount,
|
|
||||||
description: fineDesc.trim(),
|
|
||||||
status: 'pending',
|
|
||||||
due_date: dueIso,
|
|
||||||
type: 'receita'
|
|
||||||
};
|
|
||||||
tasks.push(
|
|
||||||
supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.insert(finePayload)
|
|
||||||
.then(({ error }) => {
|
|
||||||
if (error) {
|
|
||||||
console.warn('[Fase5] INSERT multa falhou:', error?.message, 'payload:', finePayload);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord).
|
|
||||||
// A sessão não aconteceu/foi cancelada → original substituída pela
|
|
||||||
// multa (se aplicada) ou simplesmente cancelada. Sem isso cobrava
|
|
||||||
// dobrado: original R$200 pending + multa R$30 = R$230. Audit trail
|
|
||||||
// preserva original em notes.
|
|
||||||
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
|
|
||||||
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
|
|
||||||
const reasonText = decision.applyFine
|
|
||||||
? novoStatus === 'faltou'
|
|
||||||
? 'Cancelada — substituída por multa de no-show'
|
|
||||||
: 'Cancelada — substituída por taxa de cancelamento tardio'
|
|
||||||
: novoStatus === 'faltou'
|
|
||||||
? 'Cancelada — sessão não realizada (paciente faltou)'
|
|
||||||
: 'Cancelada — sessão cancelada';
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const noteEntry = `[${today}] ${reasonText}`;
|
|
||||||
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
|
|
||||||
tasks.push(
|
|
||||||
supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.update({
|
|
||||||
status: 'cancelled',
|
|
||||||
notes: noteText,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', ctx.pendingRecord.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
|
|
||||||
if (decision.markPaid && ctx.pendingRecord?.id) {
|
|
||||||
tasks.push(
|
|
||||||
supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.update({
|
|
||||||
status: 'paid',
|
|
||||||
paid_at: new Date().toISOString(),
|
|
||||||
payment_method: decision.paymentMethod || 'pix',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', ctx.pendingRecord.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12: antecipou)
|
|
||||||
// Sessão já paga via "Antecipar pagamento" anteriormente. Realizada
|
|
||||||
// agora não deve gerar record novo (duplicaria cobrança) — só
|
|
||||||
// amarrar contrato (via tasks 1b) + incrementar saldo. Rodamos os
|
|
||||||
// tasks pendentes antes do incremento pra não perder o link.
|
|
||||||
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
|
|
||||||
if (hasAnticipatedPayment) {
|
|
||||||
// Roda tasks acumulados (basicamente 1b amarra) antes do incremento
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
const results = await Promise.allSettled(tasks);
|
|
||||||
const failed = results.filter((r) => r.status === 'rejected');
|
|
||||||
if (failed.length > 0) {
|
|
||||||
console.warn('[Fase5/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { data: freshContract, error: fetchErr } = await supabase
|
|
||||||
.from('billing_contracts')
|
|
||||||
.select('sessions_used, total_sessions, status')
|
|
||||||
.eq('id', ctx.billingContract.id)
|
|
||||||
.maybeSingle();
|
|
||||||
if (fetchErr) throw fetchErr;
|
|
||||||
const currentUsed = freshContract?.sessions_used ?? 0;
|
|
||||||
const newUsed = currentUsed + 1;
|
|
||||||
const patch = { sessions_used: newUsed };
|
|
||||||
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
|
|
||||||
patch.status = 'completed';
|
|
||||||
}
|
|
||||||
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
|
||||||
if (incErr) throw incErr;
|
|
||||||
toast.add({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5/realizada-paid] erro consumindo saldo:', e?.message);
|
|
||||||
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Realizado em pacote saldo: amarra contract + cria cobrança + incrementa saldo
|
|
||||||
// Refatorado pra usar AWAITS SEQUENCIAIS (igual onUsarSessao do MelissaLayout).
|
|
||||||
// Antes era Promise.allSettled paralelo que escondia falhas silenciosas
|
|
||||||
// — durante teste C11/A o sessions_used não incrementava + agenda_evento
|
|
||||||
// ficava sem billing_contract_id. Ambos os updates não rodavam mas o
|
|
||||||
// toast warn não aparecia. Agora cada step tem error explícito.
|
|
||||||
if (decision.generatePackageCharge && ctx.billingContract?.id) {
|
|
||||||
const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0));
|
|
||||||
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// 4a) Amarra agenda_evento ao contrato. Pra virtual recém-materializada,
|
|
||||||
// _applyStatusUpdateOnly criou o evento SEM billing_contract_id —
|
|
||||||
// precisa update separado aqui.
|
|
||||||
try {
|
|
||||||
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
|
|
||||||
if (linkErr) throw linkErr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5] erro amarrando billing_contract_id:', e?.message);
|
|
||||||
toast.add({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4b) Cria financial_record (RPC tolera idempotência)
|
|
||||||
try {
|
|
||||||
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
|
|
||||||
p_tenant_id: tenantId,
|
|
||||||
p_owner_id: uid,
|
|
||||||
p_patient_id: patientId,
|
|
||||||
p_agenda_evento_id: eventoId,
|
|
||||||
p_amount: amount,
|
|
||||||
p_due_date: dueIso
|
|
||||||
});
|
|
||||||
if (rpcErr) throw rpcErr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5] erro RPC create_financial_record_for_session:', e?.message);
|
|
||||||
toast.add({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4c) Incrementa sessions_used + completa contract se atingir total
|
|
||||||
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse
|
|
||||||
// campo causa "column does not exist" silenciosamente em
|
|
||||||
// Promise.allSettled (era o root cause do saldo não incrementar).
|
|
||||||
try {
|
|
||||||
const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1;
|
|
||||||
const patchContract = { sessions_used: newUsed };
|
|
||||||
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
|
|
||||||
patchContract.status = 'completed';
|
|
||||||
}
|
|
||||||
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
|
|
||||||
if (incErr) throw incErr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Fase5] erro incrementando sessions_used:', e?.message);
|
|
||||||
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roda tudo em paralelo (falha parcial é tolerável — toast warn)
|
|
||||||
const results = await Promise.allSettled(tasks);
|
|
||||||
const failed = results.filter((r) => r.status === 'rejected');
|
|
||||||
if (failed.length > 0) {
|
|
||||||
const firstErr = failed[0].reason?.message || 'sem detalhe';
|
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
|
|
||||||
console.error('[Fase5] falhas em _applyStatusDecisions:', failed.map((f) => f.reason));
|
|
||||||
} else if (tasks.length > 0) {
|
|
||||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pós-processamento do record gerado pelo pacote saldo. Agora o
|
|
||||||
// decision tem markPaid explícito:
|
|
||||||
// - markPaid=true → vira paid + payment_method=PIX/dinheiro/etc
|
|
||||||
// - markPaid=false + paymentMethod='link' → pending + payment_method='asaas'
|
|
||||||
// - markPaid=false + paymentMethod='pending' → pending sem método (default)
|
|
||||||
if (decision.generatePackageCharge && eventoId) {
|
|
||||||
try {
|
|
||||||
const { data: newRec } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.select('id')
|
|
||||||
.eq('agenda_evento_id', eventoId)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
if (newRec?.id) {
|
|
||||||
if (decision.markPaid) {
|
|
||||||
await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.update({
|
|
||||||
status: 'paid',
|
|
||||||
paid_at: new Date().toISOString(),
|
|
||||||
payment_method: decision.paymentMethod,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', newRec.id);
|
|
||||||
} else if (decision.paymentMethod === 'link') {
|
|
||||||
await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
|
|
||||||
.eq('id', newRec.id);
|
|
||||||
}
|
|
||||||
// markPaid=false + paymentMethod='pending' → não faz nada
|
|
||||||
// (record já criado como pending pelo RPC, sem payment_method)
|
|
||||||
}
|
|
||||||
} catch { /* silencioso */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
|
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
|
||||||
const onDialogSave = _buildOnDialogSave(deps);
|
const onDialogSave = _buildOnDialogSave(deps);
|
||||||
const onDialogDelete = _buildOnDialogDelete(deps);
|
const onDialogDelete = _buildOnDialogDelete(deps);
|
||||||
@@ -2018,7 +1485,8 @@ function _buildOnDialogSave(deps) {
|
|||||||
let chargeInfo = null;
|
let chargeInfo = null;
|
||||||
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
||||||
if (recChargeMode === 'package') {
|
if (recChargeMode === 'package') {
|
||||||
chargeInfo = await _createPackageContract({
|
chargeInfo = await _createPackageContractService({
|
||||||
|
supabase,
|
||||||
rule: createdRule,
|
rule: createdRule,
|
||||||
normalized,
|
normalized,
|
||||||
recorrencia,
|
recorrencia,
|
||||||
@@ -2028,7 +1496,7 @@ function _buildOnDialogSave(deps) {
|
|||||||
markPaidNow: arg?.markPaidNow === true
|
markPaidNow: arg?.markPaidNow === true
|
||||||
});
|
});
|
||||||
} else if (recChargeMode === 'per_session') {
|
} else if (recChargeMode === 'per_session') {
|
||||||
chargeInfo = await _materializeAndChargePerSession({ rule: createdRule, normalized, recorrencia, tenantId: clinicId });
|
chargeInfo = await _materializeAndChargePerSessionService({ supabase, rule: createdRule, normalized, recorrencia, tenantId: clinicId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2570,294 +2038,6 @@ function _buildOnDialogDelete(deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
|
||||||
// Helpers de cobrança em série (Opção C1, 2026-05-13)
|
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
|
||||||
|
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
|
||||||
// Calcula valor total da série a partir dos commitmentItems.
|
|
||||||
function _computeSeriePrice(recorrencia) {
|
|
||||||
const items = recorrencia.commitmentItems || [];
|
|
||||||
const n = recorrencia.qtdSessoes;
|
|
||||||
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
|
||||||
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
|
|
||||||
return {
|
|
||||||
n,
|
|
||||||
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
|
|
||||||
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// chargeMode='package' — 2 estilos (2026-05-14):
|
|
||||||
// - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência
|
|
||||||
// em agenda_eventos + cria 1 financial_record com valor TOTAL do pacote
|
|
||||||
// (vencimento na data da 1ª sessão). Demais ocorrências continuam virtuais.
|
|
||||||
// Suporta paymentMethod + markPaidNow — marca record como pago quando true.
|
|
||||||
// - 'saldo': só cria billing_contract (Cliniko style). Sem financial_record
|
|
||||||
// imediato — cobranças individuais nascem conforme sessões.
|
|
||||||
async function _createPackageContract({ rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
|
|
||||||
const { n, packagePrice } = _computeSeriePrice(recorrencia);
|
|
||||||
try {
|
|
||||||
// 1) billing_contract — referência do pacote em ambos os estilos.
|
|
||||||
// charging_style: identifica como o pacote foi cobrado na criação;
|
|
||||||
// handler de status change usa pra decidir entre "só status" (upfront)
|
|
||||||
// ou "criar cobrança + consumir saldo" (saldo).
|
|
||||||
const { data: createdContract, error: contractErr } = await supabase
|
|
||||||
.from('billing_contracts')
|
|
||||||
.insert({
|
|
||||||
owner_id: normalized.owner_id,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
patient_id: normalized.paciente_id,
|
|
||||||
type: 'package',
|
|
||||||
total_sessions: n,
|
|
||||||
sessions_used: 0,
|
|
||||||
package_price: packagePrice,
|
|
||||||
status: 'active',
|
|
||||||
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
|
|
||||||
})
|
|
||||||
.select('id')
|
|
||||||
.single();
|
|
||||||
if (contractErr) throw contractErr;
|
|
||||||
const contractId = createdContract?.id ?? null;
|
|
||||||
|
|
||||||
// Estilo 'saldo': para aqui — sem cobrança imediata.
|
|
||||||
if (packageStyle === 'saldo') {
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'success',
|
|
||||||
summary: 'Pacote criado (saldo)',
|
|
||||||
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
|
|
||||||
life: 3500
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estilo 'upfront': materializa 1ª ocorrência + 1 financial_record total.
|
|
||||||
const durMin = rule.duration_min || 50;
|
|
||||||
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
|
||||||
const firstISO = rule.start_date;
|
|
||||||
const startDt = new Date(`${firstISO}T00:00:00`);
|
|
||||||
startDt.setHours(hh, mm, 0, 0);
|
|
||||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
|
||||||
|
|
||||||
const { data: createdEvent, error: evErr } = await supabase
|
|
||||||
.from('agenda_eventos')
|
|
||||||
.insert({
|
|
||||||
owner_id: rule.owner_id,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
terapeuta_id: rule.therapist_id ?? null,
|
|
||||||
recurrence_id: rule.id,
|
|
||||||
recurrence_date: firstISO,
|
|
||||||
tipo: 'sessao',
|
|
||||||
status: 'agendado',
|
|
||||||
titulo: normalized.titulo || 'Sessão',
|
|
||||||
inicio_em: startDt.toISOString(),
|
|
||||||
fim_em: endDt.toISOString(),
|
|
||||||
patient_id: normalized.paciente_id,
|
|
||||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
|
||||||
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
|
||||||
price: packagePrice,
|
|
||||||
billing_contract_id: contractId,
|
|
||||||
visibility_scope: normalized.visibility_scope || 'public'
|
|
||||||
})
|
|
||||||
.select('id')
|
|
||||||
.single();
|
|
||||||
if (evErr) throw evErr;
|
|
||||||
|
|
||||||
// 2) financial_record do pacote total via RPC.
|
|
||||||
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
|
||||||
p_tenant_id: tenantId,
|
|
||||||
p_owner_id: rule.owner_id,
|
|
||||||
p_patient_id: normalized.paciente_id ?? null,
|
|
||||||
p_agenda_evento_id: createdEvent.id,
|
|
||||||
p_amount: packagePrice,
|
|
||||||
p_due_date: firstISO
|
|
||||||
});
|
|
||||||
if (cobErr) throw cobErr;
|
|
||||||
|
|
||||||
// 3) Pós-RPC: ajusta payment_method (sempre) e status (só se markPaidNow=true).
|
|
||||||
// method='link' → payment_method='asaas', status pending
|
|
||||||
// method=pix/etc + markPaidNow=false → payment_method=<>, status pending
|
|
||||||
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
|
|
||||||
const paidNow = markPaidNow === true && paymentMethod !== 'link';
|
|
||||||
const { data: recRow } = await supabase
|
|
||||||
.from('financial_records')
|
|
||||||
.select('id')
|
|
||||||
.eq('agenda_evento_id', createdEvent.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
if (recRow?.id) {
|
|
||||||
const patch = {
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
|
|
||||||
};
|
|
||||||
if (paidNow) {
|
|
||||||
patch.status = 'paid';
|
|
||||||
patch.paid_at = new Date().toISOString();
|
|
||||||
}
|
|
||||||
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const methodLabel = {
|
|
||||||
pix: 'PIX',
|
|
||||||
dinheiro: 'dinheiro',
|
|
||||||
deposito: 'depósito',
|
|
||||||
cartao_maquininha: 'cartão (maquininha)'
|
|
||||||
}[paymentMethod] || null;
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'success',
|
|
||||||
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
|
|
||||||
detail: paidNow
|
|
||||||
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
|
|
||||||
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
|
|
||||||
life: 4000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Pacote não gerado',
|
|
||||||
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
|
|
||||||
life: 5000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chargeMode='per_session': materializa todas as N ocorrências como
|
|
||||||
// agenda_eventos reais + cria 1 financial_record por ocorrência.
|
|
||||||
// Respeita recurrence_exceptions (feriado_block / cancel_session) — não
|
|
||||||
// materializa nessas datas. Falha parcial é tolerada (toast warn).
|
|
||||||
async function _materializeAndChargePerSession({ rule, normalized, recorrencia, tenantId }) {
|
|
||||||
const { n, perSessao } = _computeSeriePrice(recorrencia);
|
|
||||||
try {
|
|
||||||
// 1) Gerar a lista de datas das N ocorrências respeitando exceptions.
|
|
||||||
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
|
|
||||||
const dates = _generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
|
|
||||||
|
|
||||||
// 2) Montar rows pra inserção em agenda_eventos.
|
|
||||||
const durMin = rule.duration_min || 50;
|
|
||||||
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
|
||||||
const rows = dates.map((iso) => {
|
|
||||||
const startDt = new Date(`${iso}T00:00:00`);
|
|
||||||
startDt.setHours(hh, mm, 0, 0);
|
|
||||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
|
||||||
return {
|
|
||||||
owner_id: rule.owner_id,
|
|
||||||
tenant_id: tenantId,
|
|
||||||
terapeuta_id: rule.therapist_id ?? null,
|
|
||||||
recurrence_id: rule.id,
|
|
||||||
recurrence_date: iso,
|
|
||||||
tipo: 'sessao',
|
|
||||||
status: 'agendado',
|
|
||||||
titulo: normalized.titulo || 'Sessão',
|
|
||||||
inicio_em: startDt.toISOString(),
|
|
||||||
fim_em: endDt.toISOString(),
|
|
||||||
patient_id: normalized.paciente_id,
|
|
||||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
|
||||||
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
|
||||||
price: perSessao,
|
|
||||||
visibility_scope: normalized.visibility_scope || 'public'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3) Insert batch dos eventos.
|
|
||||||
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
|
|
||||||
if (evErr) throw evErr;
|
|
||||||
|
|
||||||
// 4) Pra cada evento criado, criar financial_record via RPC. Loop
|
|
||||||
// sequencial pra simplificar — N=4 é pouco; se virar gargalo, batchify.
|
|
||||||
let okCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
for (const ev of createdEvents || []) {
|
|
||||||
try {
|
|
||||||
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
|
||||||
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
|
||||||
p_tenant_id: tenantId,
|
|
||||||
p_owner_id: rule.owner_id,
|
|
||||||
p_patient_id: normalized.paciente_id ?? null,
|
|
||||||
p_agenda_evento_id: ev.id,
|
|
||||||
p_amount: perSessao,
|
|
||||||
p_due_date: dueDate
|
|
||||||
});
|
|
||||||
if (cobErr) throw cobErr;
|
|
||||||
okCount++;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failCount === 0) {
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'success',
|
|
||||||
summary: `${okCount} cobranças geradas`,
|
|
||||||
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
|
|
||||||
life: 4000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Cobranças parcialmente geradas',
|
|
||||||
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
|
|
||||||
life: 6000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
toast: {
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Falha ao materializar série',
|
|
||||||
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
|
|
||||||
life: 6000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas
|
|
||||||
// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1
|
|
||||||
// ou 2 pra quinzenal) e custom_weekdays.
|
|
||||||
function _generateOccurrenceDates(rule, max, exceptionDates) {
|
|
||||||
const dates = [];
|
|
||||||
const start = new Date(`${rule.start_date}T00:00:00`);
|
|
||||||
const interval = Math.max(1, rule.interval || 1);
|
|
||||||
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()];
|
|
||||||
const isCustom = rule.type === 'custom_weekdays';
|
|
||||||
|
|
||||||
const cursor = new Date(start);
|
|
||||||
let safety = 0;
|
|
||||||
// Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14.
|
|
||||||
// Para custom_weekdays, avança 1 dia e filtra weekdays.includes.
|
|
||||||
while (dates.length < max && safety < 365 * 3) {
|
|
||||||
const iso = _dateToISO(cursor);
|
|
||||||
const dow = cursor.getDay();
|
|
||||||
const inWeekdays = weekdays.includes(dow);
|
|
||||||
if (inWeekdays && !exceptionDates.has(iso)) {
|
|
||||||
dates.push(iso);
|
|
||||||
}
|
|
||||||
if (isCustom) {
|
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
|
||||||
} else if (inWeekdays) {
|
|
||||||
// weekly/quinzenal: ao bater o dow, pula interval semanas
|
|
||||||
cursor.setDate(cursor.getDate() + 7 * interval);
|
|
||||||
} else {
|
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
|
||||||
}
|
|
||||||
safety++;
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _dateToISO(d) {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user