Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 661790d577 | |||
| 6807b447cb | |||
| 034c2c0f3d | |||
| 87833d4ec6 | |||
| 049dd91b9b | |||
| e7e3d1beb1 | |||
| aa587e849c | |||
| ee117eafe6 | |||
| b7f3c23ad6 | |||
| 9c518a2b44 |
@@ -1355,3 +1355,125 @@ document_templates ou setting tenants.letterhead_html.
|
||||
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
|
||||
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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — paleta de cores da agenda
|
||||
|--------------------------------------------------------------------------
|
||||
| Mapping (tipo, status, isOccurrence) → hex color. Usado pelo card do
|
||||
| FullCalendar (borderColor/backgroundColor) e popovers de evento.
|
||||
|
|
||||
| Status manda mais do que tipo: realizado/faltou/cancelado têm cores
|
||||
| dedicadas (emerald/red/slate) independentes do tipo.
|
||||
|
|
||||
| Extraído de useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function pickColor(tipo, status, isOccurrence) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
if (s === 'realizado' || s === 'realizada') return '#10b981'; // emerald-500
|
||||
if (s === 'faltou') return '#ef4444'; // red-500
|
||||
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; // slate-400
|
||||
|
||||
const t = String(tipo || '').toLowerCase();
|
||||
if (t === 'bloqueio') return '#64748b'; // slate-500
|
||||
if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; // purple-500
|
||||
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; // sky-500
|
||||
|
||||
// Sessão default — distingue virtual (violet-500) vs real (indigo-500)
|
||||
return isOccurrence ? '#8b5cf6' : '#6366f1';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — whitelist de campos do agenda_eventos
|
||||
|--------------------------------------------------------------------------
|
||||
| Whitelist canônica de campos aceitos na tabela agenda_eventos pra INSERT/
|
||||
| UPDATE via cliente. Filtra qualquer chave não-prevista (defesa contra bug
|
||||
| onde payload acidentalmente carrega field defaultado pelo banco — como
|
||||
| modalidade='presencial' do bug de 2026-05-16).
|
||||
|
|
||||
| Memoria: project_pickdbfields_whitelist.md — antes era inline em
|
||||
| useMelissaAgenda.js. Extraído na Fase A.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ALLOWED_FIELDS = [
|
||||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||||
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
||||
'inicio_em', 'fim_em', 'visibility_scope',
|
||||
'mirror_of_event_id', 'mirror_source',
|
||||
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
||||
'recurrence_id', 'recurrence_date',
|
||||
'price', 'insurance_plan_id', 'insurance_guide_number',
|
||||
'insurance_value', 'insurance_plan_service_id'
|
||||
];
|
||||
|
||||
export function pickDbFields(obj) {
|
||||
const out = {};
|
||||
for (const k of ALLOWED_FIELDS) {
|
||||
if (obj[k] !== undefined) out[k] = obj[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { ALLOWED_FIELDS };
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — utils de tipo de evento (agenda)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers puros pra classificar/normalizar tipo de evento. Extraídos de
|
||||
| useMelissaAgenda.js (Fase A da decomposição agenda) pra reuso em
|
||||
| Rail/Clínica + utility puro testável.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
||||
export const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
||||
|
||||
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
|
||||
// session_duration_min_chk permite 10–240; convencionamos 120 (2h) aqui pra
|
||||
// evitar slots gigantes acidentais. Futuro: ler de agenda_configuracoes se
|
||||
// max_session_duration_min for adicionado.
|
||||
export const MAX_SESSION_MINUTES = 120;
|
||||
|
||||
export function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
||||
const s = String(t || '').trim().toLowerCase();
|
||||
if (!s) return fallback;
|
||||
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
||||
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
||||
}
|
||||
|
||||
export function deriveEventoTipoForNewEvent(payload) {
|
||||
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
||||
const title = String(payload?.titulo || '').toLowerCase();
|
||||
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||
return EVENTO_TIPO.SESSAO;
|
||||
}
|
||||
|
||||
export function deriveTituloDefaultByTipo(tipo) {
|
||||
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — utils de tempo/data (agenda)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers puros pra manipulação de tempo na agenda. Extraídos de
|
||||
| useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(v) {
|
||||
return UUID_RX.test(String(v || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soma minutos a um time "HH:MM" e retorna "HH:MM:SS".
|
||||
* Tolerante a input vazio (default 09:00).
|
||||
*/
|
||||
export function addMinutesToTime(timeStr, minutes) {
|
||||
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
||||
const total = h * 60 + m + Number(minutes || 0);
|
||||
const hh = Math.floor(total / 60);
|
||||
const mm = total % 60;
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO timestamp → hora decimal (ex: "2026-05-21T14:30:00Z" → 14.5).
|
||||
* Usa hora local (não UTC) — propósito de exibição no calendário.
|
||||
*/
|
||||
export function isoToDecimalHour(iso) {
|
||||
if (!iso) return 0;
|
||||
const d = new Date(iso);
|
||||
return d.getHours() + d.getMinutes() / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date object → "YYYY-MM-DD" (formato ISO date sem hora).
|
||||
*/
|
||||
export function dateToISO(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const emit = defineEmits([
|
||||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
||||
'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>
|
||||
|
||||
|
||||
@@ -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 (só aparece quando há cancelled) -->
|
||||
<div v-if="lancamentosCancelledCount > 0" class="flex items-center justify-end gap-2 text-xs opacity-70">
|
||||
<span v-if="!lancamentosShowHistory">{{ lancamentosCancelledCount }} cancelado(s) ocultos.</span>
|
||||
<Button
|
||||
:label="lancamentosShowHistory ? 'Ocultar histórico' : 'Mostrar histórico'"
|
||||
:icon="lancamentosShowHistory ? 'pi pi-eye-slash' : 'pi pi-history'"
|
||||
size="small"
|
||||
text
|
||||
@click="lancamentosShowHistory = !lancamentosShowHistory"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
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 10–240; 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.
|
||||
|
||||
Reference in New Issue
Block a user