10 Commits

Author SHA1 Message Date
Leonardo 661790d577 wiki + padronizacao: agenda Fase 4 residual 70% fechada
Atualiza PADRONIZACAO.md marcando Fase 4 da agenda em sua maior
parte fechada: popover snapshot + reverse transition (ja feitos
em C11) + decomposicao A+B1+B2 (-991L useMelissaAgenda) + Fases
C+D (Rail/Clinica adotam billing core via useAgendaStatusChange) +
C12 UX iter.

Pendente: indicadores visuais 3 canais em Rail/Clinica + popover
Rail antecipar/revogar/trocar metodo + doc de ajuda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:49:14 -03:00
Leonardo 6807b447cb agenda Fase D: adapter Clinica usa agendaBilling.service
AgendaClinicaPage espelha Fase C: useAgendaStatusChange composable
+ AgendaStatusChangeConfirmDialog plugado.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:48:18 -03:00
Leonardo 034c2c0f3d agenda Fase C: adapter Rail usa agendaBilling.service
AgendaTerapeutaPage (Rail) ganha o fluxo de status change do
Melissa via novo composable useAgendaStatusChange (reusable
wrapper sobre agendaBilling.service).

src/features/agenda/composables/useAgendaStatusChange.js (novo):
- Composable Tipo A pra qualquer page que precise do flow
  load context -> dialog se necessario -> apply decisoes
- Mantem state do dialog + resolver promise
- Expoe applyStatusChange(eventoId, row, novoStatus)
- Resolve ownerId via supabase.auth + tenantId via tenantStore

AgendaTerapeutaPage:
- onUpdateSeriesEvent refatorado: materializa virtual se preciso ->
  update status -> applyStatusChange (load ctx + dialog + apply)
- AgendaStatusChangeConfirmDialog plugado no template

Antes: Rail fazia so update(id, { status }) cru — sem multa,
sem pacote, sem reverse, sem nada de C7-C13. Era a versao
primitiva do status change.

Depois: Rail tem feature parity com Melissa pra status change.
Multa por falta, taxa de cancelamento tardio, consumir saldo do
pacote, gerar cobranca de pacote saldo, reverse transition trava
— tudo via mesmo agendaBilling.service.

Pendente Fase C: indicadores visuais (3 canais) + antecipar
pagamento (popover-specific, depende refactor maior do
AgendaEventDialog ou criar Rail popover). Fica pra iter
incremental.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:46:35 -03:00
Leonardo 87833d4ec6 wiki log: agenda Fase B (B1+B2) — agendaBilling.service extraido
Registra a decomposicao end-to-end (A+B1+B2) totalizando -991L
no useMelissaAgenda. 3 layouts podem agora compartilhar o billing
core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:32 -03:00
Leonardo 049dd91b9b agenda Fase B2: extrai mutations pro agendaBilling.service
Continua decomposicao da agenda. Extrai 3 mutations:
- applyStatusDecisions          (~330L — reverse, consume saldo,
                                  multa, mark paid, generate package
                                  charge, antecipated payment)
- createPackageContract         (~140L — upfront ou saldo)
- materializeAndChargePerSession (~90L — N events + N records)

Padrao das assinaturas:
- supabase como dep explicita (em vez de closure)
- toast OPCIONAL (callsite fora de UI pode passar null;
  applyStatusDecisions ramifica via `if (toast?.add)`)
- ownerId/tenantId como args (em vez de capturar refs)

createPackageContract + materializeAndChargePerSession ja retornavam
{ toast: {...} } pra caller mostrar — pattern preservado.

useMelissaAgenda.js: 2593L -> 2042L (-551L). 3 wrappers finos
injetam supabase/toast/refs do escopo do composable. Comportamento
identico — codigo movido linha-a-linha, so refactor de signature.

TOTAL nas fases A+B1+B2: -1525L extraidas do useMelissaAgenda
(de 3033L original pra 2042L atual). Tres pages (Melissa/Rail/
Clinica) agora podem reusar mesmo billing core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:09 -03:00
Leonardo e7e3d1beb1 agenda Fase B1: agendaBilling.service (read-only + helpers puros)
Continua decomposicao da agenda (apos Fase A utils). Extrai pro
service os componentes read-only / pure:

- computeSeriePrice          (puro)
- generateOccurrenceDates    (puro)
- loadStatusChangeContext    (read-only DB — assina supabase,
                              ownerId, tenantId, row, eventoId,
                              status)
- needsStatusConfirmDialog   (puro — depende so do ctx)

useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext
agora e wrapper fino que injeta supabase/ownerId/tenantId do
composable scope. _needsConfirmDialog vira alias direto.
_computeSeriePrice/_generateOccurrenceDates importados direto.

Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica
read-only de status change. Mutations (applyStatusDecisions,
createPackageContract, materializeAndChargePerSession) ficam pra
Fase B2.

Risco: zero comportamental — toda chamada produz o mesmo ctx
de antes. Codigo movido sem mudancas de logica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:30:06 -03:00
Leonardo aa587e849c wiki log: C12 UX iterado + agenda Fase A utils extract
Registra os 3 commits da sessao (C12 trocar metodo, C12 filtro
cancelled, Fase A utils extract). Memoria
project_c12_antecipar_iterar atualizada pra refletir patterns
prontos pra Rail/Clinica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:22:10 -03:00
Leonardo ee117eafe6 agenda Fase A: extrai utils puros pra features/agenda/utils
Decomposicao da agenda em prep pra replicar Rail/Clinica.

4 arquivos novos em src/features/agenda/utils/:
- eventoTipo.js  -> EVENTO_TIPO + normalize/derive + MAX_SESSION_MINUTES
- dbFields.js    -> pickDbFields whitelist (memoria pickdbfields_whitelist)
- timeHelpers.js -> isUuid + addMinutesToTime + isoToDecimalHour + dateToISO
- colors.js      -> pickColor (status+tipo+isOccurrence)

useMelissaAgenda.js (2863L -> 2792L): removeu definicoes locais
(83 linhas), passou a importar dos utils. Aliases _addMinutesToTime
e _dateToISO mantidos no escopo via import "as" pra nao mexer
em 70+ callsites internos.

Fase A = baseline zero-comportamental pra Rail/Clinica adotarem
os mesmos helpers. Fase B (service de billing — applyStatusDecisions,
createPackageContract, materializeAndCharge) vem em seguida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:21:12 -03:00
Leonardo b7f3c23ad6 agenda C12 UX: filtrar cancelled do dialog Lancamentos da sessao
Iteracao UX #2 do C12: records cancelled (do ciclo Revogar+Antecipar
e tambem das multas) poluiam o dialog "Lancamentos da sessao",
escondendo o que importa (ativos).

lancamentosShowHistory ref (default false) + lancamentosFiltered
computed filtra status !== 'cancelled'. lancamentosCancelledCount
computa contagem pra feedback.

UI:
- Dialog abre limpo (sempre lancamentosShowHistory=false em
  onVerLancamentos)
- Quando ha cancelled e existe ativo: linha acima da lista com
  "{N} cancelado(s) ocultos" + botao toggle "Mostrar/Ocultar
  historico"
- Quando todos sao cancelled: empty state especial "Sem
  lancamentos ativos. {N} cancelado(s) no historico" + botao
  pra expandir
- Cards cancelled atenuados (opacity 0.55, border-dashed,
  background sutil, description com line-through) — claramente
  audit trail, nao-ativo

Combina com "Trocar metodo" (commit anterior) — agora o caso 99%
do tempo ele ve so o record ativo, nao precisa nem expandir
historico.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:31:01 -03:00
Leonardo 9c518a2b44 agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
Iteracao UX do C12 (antecipar pagamento) — antes user que queria
trocar PIX por dinheiro precisava Revogar (cancela record) +
Antecipar de novo (cria record novo), acumulando lixo no audit
trail (memoria project_c12_antecipar_iterar: ciclos longos chegaram
a 5+ records cancelled num mesmo evento).

MelissaEventoPanel ganha 3 botoes quando isAntecipacaoAtiva:
  - "Trocar metodo"   (default, icone pi-sync)
  - "Revogar pagamento" (danger, icone pi-times-circle)
Antes mostrava so "Revogar".

MelissaLayout:
- anteciparMode ref ('create' | 'update') + onTrocarMetodoAntecipacao
  pre-seleciona o metodo atual lendo o paid record antes de abrir
  o dialog
- confirmAnteciparPagamento ramifica: mode='update' faz UPDATE no
  paid existente (payment_method + paid_at + notes audit "metodo
  trocado: X -> Y"). Sem cancel cycle, sem record novo.
- Dialog header/labels/CTA dinamicos por mode

Result: ciclo trocar metodo agora gera 0 records cancelled (so
update + nota auditoria). Revogar continua disponivel pra quando
realmente precisar cancelar o pagamento.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:29:02 -03:00
13 changed files with 1621 additions and 953 deletions
+122
View File
@@ -1355,3 +1355,125 @@ document_templates ou setting tenants.letterhead_html.
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
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
+15 -6
View File
@@ -123,13 +123,22 @@ Do `project_graphify_findings_20260504`:
- [ ] E2E Playwright crítico (#16)
- [ ] Sentry (#18)
### Fase 4 — Agenda residual (por último)
### Fase 4 — Agenda residual
- [ ] Popover snapshot stale`ev.id` + computed
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
- [ ] C12 antecipar — iterar UX
- [ ] Doc de ajuda completa
- [x] **Popover snapshot stale** (commit `f83315b` durante C11) — watch em `MelissaLayout` cobre virtual→materializada.
- [x] **Reverse transition confirm dialogs** (commit `5684297` durante C11) — `ctx.reverseArtifacts` + dialog.
- [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`).
- Fase A: `features/agenda/utils/{eventoTipo,dbFields,timeHelpers,colors}.js`
- 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
};
}
+78 -28
View File
@@ -36,6 +36,12 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
import { useFeriados } from '@/composables/useFeriados';
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 { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useTenantStore } from '@/stores/tenantStore';
@@ -502,6 +508,15 @@ const ownerOptions = computed(() => staffCols.value.map((p) => ({ label: p.title
// -------------------- events --------------------
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 { 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 }) {
const tid = tenantId.value;
try {
if (id) {
await updateClinic(id, { status }, { tenantId: tid });
return;
const row = dialogEventRow.value || {};
// 1) Materializar virtual se preciso (resolve eventoId real)
let eventoId = id;
if (!id) {
if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
} 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;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
// 2) Atualiza status no DB
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) {
await updateClinic(existing.id, { status }, { tenantId: tid });
} else {
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 }
);
// 4) Refetch se aplicou (UI reflete novo estado)
if (applied && typeof loadClinicRange === 'function') {
await loadClinicRange();
}
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
@@ -2463,6 +2498,21 @@ function goRecorrencias() {
<!-- Dialog de Bloqueio -->
<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 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">
@@ -49,6 +49,13 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// Fase C (replicação Rail): adopta agendaBilling.service via composable
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
// pacote saldo/upfront (C7-C13 de Melissa).
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
import { createPackageContract, materializeAndChargePerSession } from '@/features/agenda/services/agendaBilling.service';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
const router = useRouter();
@@ -119,6 +126,16 @@ watch(eventsLoading, (val) => {
if (!val) eventsHasLoaded.value = true;
});
// Fase C: orquestrador de status change (Melissa pattern). Cobre confirm
// dialog + multa + reverse + pacote saldo/upfront via agendaBilling.service.
const {
dialogOpen: statusDialogOpen,
dialogProps: statusDialogProps,
onDialogConfirm: onStatusDialogConfirm,
onDialogCancel: onStatusDialogCancel,
applyStatusChange
} = useAgendaStatusChange({ toast });
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
@@ -1711,37 +1728,60 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
try {
if (id) {
await update(id, { status });
return;
const row = dialogEventRow.value || {};
// 1) Materializar virtual se preciso (resolve `eventoId` real)
let eventoId = id;
if (!id) {
if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
// Status atualiza só depois do dialog/applyStatusChange decidir
} else {
// Materializa criando com status='agendado' — o status final
// é aplicado por applyStatusChange (que pode ramificar pelo
// dialog se houver decisão a tomar)
const created = await create({
owner_id: ownerId.value,
tenant_id: clinicTenantId.value,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
});
eventoId = created?.id || null;
}
}
if (!is_virtual || !inicio_em) return;
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
// 2) Atualiza status NO DB (applyStatusChange só cuida de billing — não
// do status do agenda_evento em si). Antes do dialog pra ctx.row
// refletir o novo status do evento.
if (eventoId) {
await update(eventoId, { status });
}
// Verifica se já foi materializado antes (evita violação de constraint)
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
// 3) Aplica fluxo de billing (load context + dialog se preciso + apply)
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
if (existing?.id) {
await update(existing.id, { status });
} else {
const row = dialogEventRow.value || {};
await create({
owner_id: ownerId.value,
tenant_id: clinicTenantId.value,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status,
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
});
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
if (applied) {
await loadMyRange?.();
}
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
@@ -3303,6 +3343,21 @@ onBeforeUnmount(() => {
<!-- Dialog: Prontuário -->
<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>
<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
}
};
}
}
+28
View File
@@ -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';
}
+34
View File
@@ -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 };
+37
View File
@@ -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 10240; 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';
}
+46
View File
@@ -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}`;
}
+21 -10
View File
@@ -40,6 +40,7 @@ const emit = defineEmits([
'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
'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
'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
@@ -507,16 +508,26 @@ function modalidadeIcon(mod) {
<i class="pi pi-money-bill" />
<span class="evento-act__label">Antecipar pagamento</span>
</button>
<button
v-else
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Desfazer o pagamento antecipado cancela o lançamento e libera pra antecipar de novo'"
@click="emit('revogar-antecipacao')"
>
<i class="pi pi-times-circle" />
<span class="evento-act__label">Revogar pagamento</span>
</button>
<template v-else>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Atualizar a forma de pagamento sem cancelar o registro (não gera lixo no histórico)'"
@click="emit('trocar-metodo-antecipacao')"
>
<i class="pi pi-sync" />
<span class="evento-act__label">Trocar método</span>
</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>
</section>
+140 -7
View File
@@ -751,6 +751,17 @@ const lancamentosDialogOpen = ref(false);
const lancamentosList = ref([]);
const lancamentosLoading = ref(false);
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
// 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
@@ -759,6 +770,9 @@ const anteciparDialogOpen = ref(false);
const anteciparMethod = ref('pix');
const anteciparBusy = ref(false);
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 = [
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
@@ -782,6 +796,39 @@ async function onAnteciparPagamento() {
}
anteciparEventoRef.value = ev;
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;
}
@@ -790,6 +837,53 @@ async function confirmAnteciparPagamento() {
if (!ev || anteciparBusy.value) return;
anteciparBusy.value = true;
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;
// ownerId: ev.owner_id é prioridade. Fallback pra M.ownerId (composable
// 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;
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosShowHistory.value = false; // sempre abre limpo (sem cancelled)
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
@@ -2629,6 +2724,7 @@ function onKeydown(e) {
@revogar-sessao="onRevogarSessao"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@trocar-metodo-antecipacao="onTrocarMetodoAntecipacao"
@revogar-antecipacao="onRevogarAntecipacao"
@edit-paciente="onEditPaciente"
@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">
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
</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">
<!-- Toggle de histórico ( aparece quando 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
v-for="(r, idx) in lancamentosList"
v-for="(r, idx) in lancamentosFiltered"
:key="r.id"
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">
<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"
modal
:draggable="false"
header="Antecipar pagamento"
:header="anteciparMode === 'update' ? 'Trocar método de pagamento' : 'Antecipar pagamento'"
:style="{ width: '480px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3 pt-1">
<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 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-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
</div>
<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
v-model="anteciparMethod"
:options="anteciparMethodOptions"
@@ -3202,13 +3320,16 @@ function onKeydown(e) {
size="small"
/>
</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.
</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>
<template #footer>
<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>
</Dialog>
@@ -3761,6 +3882,18 @@ function onKeydown(e) {
margin-left: 1.5rem;
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 {
display: flex;
align-items: center;
@@ -38,84 +38,30 @@ import { useCommitmentServices } from '@/features/agenda/composables/useCommitme
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
// ─── Utilities puros (extraídos na Fase A da decomposição agenda) ───────────
// Mantidos em features/agenda/utils/ pra reuso em Rail/Clínica.
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
// `session_duration_min_chk` permite 10240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
// `max_session_duration_min` for adicionado.
const MAX_SESSION_MINUTES = 120;
function isUuid(v) {
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 || ''));
}
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;
}
// ─── Service de billing (Fase B1 read-only + Fase B2 mutations) ────────────
import {
computeSeriePrice as _computeSeriePrice,
generateOccurrenceDates as _generateOccurrenceDates,
loadStatusChangeContext,
needsStatusConfirmDialog,
applyStatusDecisions,
createPackageContract as _createPackageContractService,
materializeAndChargePerSession as _materializeAndChargePerSessionService
} from '@/features/agenda/services/agendaBilling.service';
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
// 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.
// 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 }) {
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: 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 && clinicTenantId.value) {
try {
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;
return loadStatusChangeContext({
supabase,
row,
eventoId,
status,
ownerId: ownerId.value,
tenantId: clinicTenantId.value
});
}
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
// OU pacote saldo OU pacote upfront OU pending record (realizado).
function _needsConfirmDialog(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) {
// 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;
}
// _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
// do agendaBilling.service. Pure — sem deps de composable state.
const _needsConfirmDialog = needsStatusConfirmDialog;
// 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 }) {
const tenantId = clinicTenantId.value;
const uid = ownerId.value;
const patientId = row.patient_id ?? row.paciente_id ?? null;
const tasks = [];
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
// Tratado antes dos blocos forward porque a lógica é distinta —
// cancelar records, devolver saldo, sem multa nova. Status já foi
// atualizado pelo _applyStatusUpdateOnly antes desta função.
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
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 */ }
}
return applyStatusDecisions({
supabase,
toast,
eventoId,
row,
novoStatus,
ctx,
decision,
ownerId: ownerId.value,
tenantId: clinicTenantId.value
});
}
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
const onDialogSave = _buildOnDialogSave(deps);
const onDialogDelete = _buildOnDialogDelete(deps);
@@ -2018,7 +1485,8 @@ function _buildOnDialogSave(deps) {
let chargeInfo = null;
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
if (recChargeMode === 'package') {
chargeInfo = await _createPackageContract({
chargeInfo = await _createPackageContractService({
supabase,
rule: createdRule,
normalized,
recorrencia,
@@ -2028,7 +1496,7 @@ function _buildOnDialogSave(deps) {
markPaidNow: arg?.markPaidNow === true
});
} 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' });
// 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}`;
}
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.