agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX

Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
  Visual: 12 virtuais limpas no calendário.

UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
  (id, style, totalSessions, sessionsUsed, packagePrice) por
  recurrence_id. Query recurrence_rules.patient_id como fonte
  autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
  via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
  com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
  modelo, gateado por occFinancialLoading (spinner durante carga
  pra evitar piscar entre Usar/Revogar)

Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
  determined_commitment_id da regra) → status=realizado +
  billing_contract_id → create_financial_record_for_session →
  sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
  se estava completed + status=agendado. Bloqueia se record paid
  (precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
  pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
  "Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
  "Revogar uso" no info card do dialog

Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
  Corrigido em todas as ocorrências do handler

Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
  isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
  path); update path backfilla via query da rule se NULL; Revogar
  também backfilla pra consistência

Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
  saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
  sessions_used. Fluxo correto: "Usar"

Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
  faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
  faltou, stone-500 cancelado, violet-600 remarcado)

Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
  occFinancialRecord async; durante ~500ms de load, botão errado
  podia piscar. Fix: spinner "Verificando estado…" enquanto
  occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)

3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 23:27:20 -03:00
parent 1feb7112ff
commit fad1f4ebd4
7 changed files with 893 additions and 57 deletions
+134 -2
View File
@@ -38,7 +38,9 @@ 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)
'gerar-cobranca' // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
]);
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
@@ -74,6 +76,31 @@ const seriesLabel = computed(() => {
return 'Série recorrente';
});
// Info de contrato (saldo/upfront) — vem injetada pelo bulk-load via
// _ruleContractMap. Mostra linha no popover indicando o tipo de pacote
// e progresso (usadas/total). Pra saldo é especialmente importante —
// sem essa linha o user não sabe o que está acontecendo (nenhuma
// cobrança no /financeiro até realizar).
const contractInfo = computed(() => {
const c = ev.value.contract;
if (!c) return null;
const total = c.totalSessions || 0;
const used = c.sessionsUsed || 0;
const remaining = Math.max(0, total - used);
const perSession = total > 0 ? (c.packagePrice || 0) / total : 0;
return {
style: c.style || 'upfront',
total,
used,
remaining,
packagePrice: c.packagePrice || 0,
perSession,
label: c.style === 'saldo'
? `Pacote saldo · ${used}/${total} usadas`
: `Pacote · ${used}/${total} realizadas`
};
});
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
@@ -250,8 +277,13 @@ function modalidadeIcon(mod) {
com paymentState='none' (cobrança ainda não gerada).
Pago/pendente existe um record; nesses casos não
cabe gerar de novo. -->
<!-- "Gerar fatura" faz sentido pra sessão SEM contrato
de pacote OU sem saldo ativo. Em sessão de saldo o
fluxo correto é "Usar" (que orquestra cobrança +
sessions_used) gerar fatura solta aqui criaria
cobrança duplicada e dessincronizaria o saldo. -->
<button
v-if="paymentVariant === 'none' && !ev.is_occurrence"
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo"
type="button"
class="evento-row__pay-action"
:disabled="busy"
@@ -263,6 +295,44 @@ function modalidadeIcon(mod) {
</button>
</div>
<!-- Linha de info do contrato (saldo/upfront) quando a
sessão pertence a uma série com billing_contract ativo.
Pra saldo é a única forma do user entender que tem um
pacote sem cobrança no /financeiro até realizar.
Botão "Usar" pra saldo com saldo disponível
materializa+realizada+gera cobrança em 1 click. -->
<div v-if="contractInfo" class="evento-row evento-row--contract" :class="`evento-row--contract-${contractInfo.style}`">
<i class="pi pi-box" />
<span>{{ contractInfo.label }}</span>
<!-- Revogar: aparece quando sessão foi "usada" (record
pending vinculado). Bloqueia pra paid (precisa estorno
formal pelo Financeiro). -->
<button
v-if="contractInfo.style === 'saldo' && !ev.is_occurrence && ev.paymentState === 'pending'"
type="button"
class="evento-row__pay-action evento-row__pay-action--revogar"
:disabled="busy"
v-tooltip.top="'Desfaz o uso (cancela cobrança e devolve 1 ao saldo)'"
@click="emit('revogar-sessao')"
>
<i class="pi pi-undo" />
<span>Revogar</span>
</button>
<!-- Usar: aparece quando saldo + sessão ainda não usada
(virtual OU materializada sem cobrança ativa). -->
<button
v-else-if="contractInfo.style === 'saldo' && contractInfo.remaining > 0 && (ev.is_occurrence || ev.paymentState === 'none')"
type="button"
class="evento-row__pay-action evento-row__pay-action--contract"
:disabled="busy"
v-tooltip.top="'Marca sessão como realizada e gera cobrança (1 do saldo)'"
@click="emit('usar-sessao')"
>
<i class="pi pi-check" />
<span>Usar</span>
</button>
</div>
<div v-if="ev.modalidade" class="evento-row">
<i :class="modalidadeIcon(ev.modalidade)" />
<span class="capitalize">{{ ev.modalidade }}</span>
@@ -607,6 +677,68 @@ html.app-dark .evento-row__pay-action {
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
}
/* Linha de contrato — exibe "Pacote saldo · N/M usadas" ou "Pacote · N/M
realizadas". Saldo em violeta (modelo Cliniko), upfront em verde
(já cobrado). Acompanha as cores do info card do AgendaEventDialog. */
.evento-row--contract {
font-weight: 500;
}
.evento-row--contract-saldo {
color: rgb(124, 58, 237); /* violet-600 */
}
.evento-row--contract-saldo > i {
color: rgb(124, 58, 237);
}
.evento-row--contract-upfront {
color: rgb(5, 150, 105); /* emerald-600 */
}
.evento-row--contract-upfront > i {
color: rgb(5, 150, 105);
}
html.app-dark .evento-row--contract-saldo {
color: #a78bfa; /* violet-400 */
}
html.app-dark .evento-row--contract-saldo > i {
color: #a78bfa;
}
html.app-dark .evento-row--contract-upfront {
color: #34d399; /* emerald-400 */
}
html.app-dark .evento-row--contract-upfront > i {
color: #34d399;
}
/* Override do pay-action quando dentro da linha de contrato — usa
violeta (saldo) pra combinar com a linha. */
.evento-row__pay-action--contract {
color: rgb(124, 58, 237) !important;
background: color-mix(in srgb, rgb(124, 58, 237) 12%, transparent) !important;
border-color: color-mix(in srgb, rgb(124, 58, 237) 36%, transparent) !important;
}
.evento-row__pay-action--contract:hover:not(:disabled) {
background: color-mix(in srgb, rgb(124, 58, 237) 22%, transparent) !important;
border-color: color-mix(in srgb, rgb(124, 58, 237) 56%, transparent) !important;
}
html.app-dark .evento-row__pay-action--contract {
color: #a78bfa !important;
background: color-mix(in srgb, #a78bfa 14%, transparent) !important;
border-color: color-mix(in srgb, #a78bfa 30%, transparent) !important;
}
/* Revogar — vermelho/amber pra sinalizar ação destrutiva. */
.evento-row__pay-action--revogar {
color: rgb(220, 38, 38) !important; /* red-600 */
background: color-mix(in srgb, rgb(220, 38, 38) 10%, transparent) !important;
border-color: color-mix(in srgb, rgb(220, 38, 38) 32%, transparent) !important;
}
.evento-row__pay-action--revogar:hover:not(:disabled) {
background: color-mix(in srgb, rgb(220, 38, 38) 18%, transparent) !important;
border-color: color-mix(in srgb, rgb(220, 38, 38) 50%, transparent) !important;
}
html.app-dark .evento-row__pay-action--revogar {
color: #f87171 !important; /* red-400 */
background: color-mix(in srgb, #f87171 14%, transparent) !important;
border-color: color-mix(in srgb, #f87171 30%, transparent) !important;
}
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
Empilhados verticalmente à direita da linha das horas. */
.evento-row__edit-stack {
+246
View File
@@ -1245,6 +1245,246 @@ async function onGerarCobrancaQuick() {
}
}
// "Usar" sessão de pacote saldo — combina em uma ação:
// 1. Materializa virtual (se ainda virtual)
// 2. Status='realizada'
// 3. Cria financial_record per_session via RPC (status='pending', dueDate=hoje)
// 4. Incrementa billing_contracts.sessions_used
// 5. Se sessions_used == total → contract.status='completed'
// Reflete o modelo Cliniko: paciente comparece, sessão é "usada" do saldo,
// nasce a cobrança individual.
async function onUsarSessao(payload = null) {
// Aceita payload do dialog ({ eventRow, contract }) OU usa fallback
// do estado do popover (eventoSelecionado + ev.contract injetado pelo
// bulk-load). Permite reuse do handler em ambos os fluxos.
const ev = payload?.eventRow || eventoSelecionado.value;
if (!ev || eventoBusy.value) return;
const contract = payload?.contract || ev?.contract;
if (!contract?.id) {
toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 });
return;
}
if (contract.style !== 'saldo') {
// Pra upfront, "Usar" não cabe (pacote já foi pago de uma vez)
toast.add({ severity: 'info', summary: 'Pacote upfront', detail: 'Este pacote já foi cobrado integralmente. Use "Realizada" pra marcar status.', life: 4000 });
return;
}
eventoBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const ownerId = M?.ownerId?.value || ev.owner_id;
const perSession = contract.totalSessions > 0 ? (contract.packagePrice || 0) / contract.totalSessions : 0;
const dueDate = ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : new Date().toISOString().slice(0, 10);
// 1) Materializa virtual se necessário
let eventoId = ev.id;
const isVirtualId = typeof eventoId === 'string' && eventoId.startsWith('rec::');
if (ev.is_occurrence || isVirtualId) {
const recurrenceDate = ev.recurrence_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
// Preserva determined_commitment_id da regra (necessário pra
// isSessionEvent=true no AgendaEventDialog; sem ele o campo
// "Título" aparece indevidamente porque o dialog acha que não
// é sessão).
const raw = ev._raw || {};
const { data: created, error: matErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: ownerId,
tenant_id: tenantId,
patient_id: ev.patient_id || ev.paciente_id,
recurrence_id: ev.recurrence_id,
recurrence_date: recurrenceDate,
tipo: 'sessao',
status: 'realizado',
titulo: ev.label || ev.titulo || raw.titulo || 'Sessão',
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
modalidade: ev.modalidade || raw.modalidade || 'presencial',
determined_commitment_id: raw.determined_commitment_id || null,
price: perSession,
billing_contract_id: contract.id,
visibility_scope: 'public'
})
.select('id')
.single();
if (matErr) throw matErr;
eventoId = created.id;
} else {
// Sessão real: atualiza status + garante link com contrato.
// Tambem backfilla determined_commitment_id se estiver NULL
// (legado de uses antigos antes do fix). Sem isso o dialog
// mostra campo "Título" indevidamente (isSessionEvent=false).
const raw = ev._raw || {};
const patch = { status: 'realizado', billing_contract_id: contract.id };
const commitmentId = raw.determined_commitment_id || null;
if (commitmentId) {
patch.determined_commitment_id = commitmentId;
} else {
// Busca da rule (fonte autoritativa) se ev não tem
const { data: rule } = await supabase
.from('recurrence_rules')
.select('determined_commitment_id')
.eq('id', ev.recurrence_id)
.maybeSingle();
if (rule?.determined_commitment_id) {
patch.determined_commitment_id = rule.determined_commitment_id;
}
}
const { error: upErr } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', eventoId);
if (upErr) throw upErr;
}
// 2) Cria financial_record per_session (RPC ignora cancelled na idempotência)
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: ev.patient_id || ev.paciente_id,
p_agenda_evento_id: eventoId,
p_amount: perSession,
p_due_date: dueDate
});
if (cobErr) throw cobErr;
// 3) Incrementa sessions_used + completa contract se necessário
const newUsed = (contract.sessionsUsed || 0) + 1;
const patchContract = { sessions_used: newUsed };
if (newUsed >= contract.totalSessions) {
patchContract.status = 'completed';
}
const { error: ctErr } = await supabase
.from('billing_contracts')
.update(patchContract)
.eq('id', contract.id);
if (ctErr) throw ctErr;
toast.add({
severity: 'success',
summary: 'Sessão usada do pacote',
detail: `Cobrança de R$ ${perSession.toFixed(2).replace('.', ',')} gerada. Saldo: ${newUsed}/${contract.totalSessions}.`,
life: 4000
});
M.refetch();
refetchEventosHoje();
// Fecha popover + dialogs eventuais (chamada pode vir do popover OU
// de qualquer um dos AgendaEventDialog empilhados)
fecharEvento();
if (M.dialogOpen) M.dialogOpen.value = false;
if (M.occDialogOpen) M.occDialogOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao usar sessão.', life: 5000 });
} finally {
eventoBusy.value = false;
}
}
// "Revogar" sessão usada do pacote saldo — desfaz onUsarSessao:
// 1. Cancela financial_record (status='cancelled')
// 2. Decrementa billing_contracts.sessions_used (se > 0)
// 3. Se contract estava completed, volta pra active
// 4. Status do evento volta pra 'agendado'
// Constraint: só funciona se record ainda pending. Pago = bloqueado (precisa
// estorno formal pelo Financeiro).
async function onRevogarSessao(payload = null) {
// Aceita payload do dialog ({ eventRow, contract }) OU usa fallback
// do popover (eventoSelecionado + ev.contract).
const ev = payload?.eventRow || eventoSelecionado.value;
if (!ev || eventoBusy.value) return;
const contract = payload?.contract || ev?.contract;
if (!contract?.id) {
toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 });
return;
}
if (!ev.id || ev.is_occurrence) {
toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Sessão ainda não foi usada.', life: 3000 });
return;
}
eventoBusy.value = true;
try {
// Acha record vinculado a essa sessão e ao contrato
const { data: recs } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', ev.id)
.neq('status', 'cancelled')
.order('created_at', { ascending: false });
const activeRec = (recs || []).find((r) => r.status !== 'cancelled');
if (activeRec && activeRec.status === 'paid') {
toast.add({
severity: 'warn',
summary: 'Cobrança já paga',
detail: 'Esta sessão já foi paga. Estorne primeiro pelo Financeiro antes de revogar.',
life: 5000
});
return;
}
// 1) Cancela record (se ainda pending)
if (activeRec) {
const { error: cancelErr } = await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', activeRec.id);
if (cancelErr) throw cancelErr;
}
// 2) Decrementa sessions_used + reativa contract se estava completed
const newUsed = Math.max(0, (contract.sessionsUsed || 0) - 1);
const patchContract = { sessions_used: newUsed };
// Se contrato estava completed (sessionsUsed == total) e agora < total, reativa
if ((contract.sessionsUsed || 0) >= contract.totalSessions) {
patchContract.status = 'active';
}
const { error: ctErr } = await supabase
.from('billing_contracts')
.update(patchContract)
.eq('id', contract.id);
if (ctErr) throw ctErr;
// 3) Status do evento volta pra agendado.
// Backfill determined_commitment_id se estiver NULL (legado de uses
// antigos antes do fix). Garante que próxima abertura do dialog
// não exiba campo "Título" indevidamente.
const raw = ev._raw || {};
const patch = { status: 'agendado' };
if (!raw.determined_commitment_id && ev.recurrence_id) {
const { data: rule } = await supabase
.from('recurrence_rules')
.select('determined_commitment_id')
.eq('id', ev.recurrence_id)
.maybeSingle();
if (rule?.determined_commitment_id) {
patch.determined_commitment_id = rule.determined_commitment_id;
}
}
const { error: upErr } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', ev.id);
if (upErr) throw upErr;
toast.add({
severity: 'success',
summary: 'Sessão revogada',
detail: `Cobrança cancelada. Saldo: ${newUsed}/${contract.totalSessions}.`,
life: 4000
});
M.refetch();
refetchEventosHoje();
fecharEvento();
if (M.dialogOpen) M.dialogOpen.value = false;
if (M.occDialogOpen) M.occDialogOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao revogar sessão.', life: 5000 });
} finally {
eventoBusy.value = false;
}
}
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -2249,6 +2489,8 @@ function onKeydown(e) {
@delete-sessao="onDeleteEvento"
@delete-series="onDeleteSeries"
@gerar-cobranca="onGerarCobrancaQuick"
@usar-sessao="onUsarSessao"
@revogar-sessao="onRevogarSessao"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente"
@@ -2688,6 +2930,8 @@ function onKeydown(e) {
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
@editSeriesOccurrence="M.onEditSeriesOccurrence"
@usar-sessao="onUsarSessao"
@revogar-sessao="onRevogarSessao"
/>
<!-- 2º AgendaEventDialog empilhado por cima do principal pra editar
@@ -2716,6 +2960,8 @@ function onKeydown(e) {
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
@usar-sessao="onUsarSessao"
@revogar-sessao="onRevogarSessao"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados.
@@ -117,7 +117,7 @@ function isoToDecimalHour(iso) {
return d.getHours() + d.getMinutes() / 60;
}
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null) {
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
const isOccurrence = !!r.is_occurrence;
@@ -161,6 +161,9 @@ function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null,
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
paymentState,
paymentAmount,
// Info do contrato (saldo/upfront) — injetado quando a série tem
// billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M".
contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null,
price: r.price != null ? Number(r.price) : null,
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
// price). Popover e Resumo usam fallback `price ?? insurance_value`
@@ -351,9 +354,12 @@ export function useMelissaAgenda() {
// têm id real e portanto não entram em _paymentStateMap), normalize lê
// daqui o estado herdado do contrato upfront pago da série.
const _rulePaymentMap = ref({});
// Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice}
// — info do billing_contract da série pra exibir no popover.
const _ruleContractMap = ref({});
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value)));
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value)));
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
// `allEvents` no shape FC pra checar conflitos) ─────────
@@ -486,6 +492,7 @@ export function useMelissaAgenda() {
const map = {};
const amountMap = {};
const ruleMap = {};
const ruleContractMap = {};
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
@@ -542,9 +549,20 @@ export function useMelissaAgenda() {
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
}
if (ruleIdsInView.size) {
// 2) Acha QUALQUER evento (em qualquer semana) das rules
// em view + seus records paid/pending pra detectar o
// estado do contrato.
// 2) Acha patient_id direto das rules (fonte autoritativa,
// funciona até quando rule não tem nenhum evento
// materializado — caso pacote saldo recém-criado).
const { data: rulesData } = await supabase
.from('recurrence_rules')
.select('id, patient_id')
.in('id', [...ruleIdsInView]);
const rulePatientFromRule = new Map();
for (const r of rulesData || []) {
if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id);
}
// 3) Acha eventos (em qualquer semana) das rules em view +
// seus records paid/pending pra detectar estado.
const { data: allRuleEvents } = await supabase
.from('agenda_eventos')
.select('id, recurrence_id, patient_id')
@@ -577,14 +595,38 @@ export function useMelissaAgenda() {
const existing = ruleToState.get(rid);
if (existing !== 'paid') ruleToState.set(rid, newState);
}
if (ruleToPatient.size) {
// Confirma contratos upfront pros pacientes envolvidos
const patientIds = [...new Set(ruleToPatient.values())];
// Busca contratos ativos pra TODOS pacientes envolvidos
// (saldo OU upfront — ambos exibem info no popover).
// Query única com todos campos necessários. Usa
// rulePatientFromRule (fonte autoritativa) pra cobrir
// saldo sem records (não passa por ruleToPatient).
const allPatientIds = [...new Set(rulePatientFromRule.values())];
let activePackages = [];
if (allPatientIds.length) {
const { data: contracts } = await supabase
.from('billing_contracts')
.select('patient_id, charging_style, status, type')
.in('patient_id', patientIds);
const activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
.select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price')
.in('patient_id', allPatientIds);
activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
}
// Index por patient_id pra lookup rápido
const contractByPatient = new Map();
for (const c of activePackages) contractByPatient.set(c.patient_id, c);
// Popula ruleContractMap pra TODAS as rules em view com
// contrato ativo (saldo + upfront, com OU sem records).
for (const [rid, pid] of rulePatientFromRule.entries()) {
const c = contractByPatient.get(pid);
if (c) {
ruleContractMap[rid] = {
id: c.id,
style: c.charging_style || 'upfront',
totalSessions: c.total_sessions || 0,
sessionsUsed: c.sessions_used || 0,
packagePrice: Number(c.package_price || 0)
};
}
}
if (ruleToPatient.size) {
// NULL charging_style → assume upfront (default histórico
// antes da migration 20260514000003). Pra dados antigos
// sem a coluna preenchida, evita virtuais ficarem sem
@@ -602,18 +644,13 @@ export function useMelissaAgenda() {
.from('agenda_eventos')
.select('id, recurrence_id')
.in('recurrence_id', ruleIdsToPropagate);
// Captura o package_price do contrato pra propagar
// valor real pra siblings (não o per-session que
// pode ter sido editado depois)
// Reusa contractByPatient da query unificada acima
// (antes havia uma 2ª query redundante pro mesmo dado).
const contractPriceByPatient = new Map();
const { data: contractsDetail } = await supabase
.from('billing_contracts')
.select('patient_id, package_price')
.in('patient_id', [...upfrontPatients])
.eq('charging_style', 'upfront')
.eq('status', 'active');
for (const c of contractsDetail || []) {
contractPriceByPatient.set(c.patient_id, c.package_price);
for (const c of activePackages) {
if (c.charging_style === 'upfront' || c.charging_style == null) {
contractPriceByPatient.set(c.patient_id, c.package_price);
}
}
for (const s of siblings || []) {
if (map[s.id] !== undefined) {
@@ -645,6 +682,7 @@ export function useMelissaAgenda() {
_paymentStateMap.value = map;
_paymentAmountMap.value = amountMap;
_rulePaymentMap.value = ruleMap;
_ruleContractMap.value = ruleContractMap;
}
async function refetch() {