agenda: C7 OK + Fase 6 lock-edit ativada em Melissa + cross-week payment propagation

Cenário 7 (Pacote UPFRONT — Ana Souza Ferreira 4×R$ 200 = R$ 800)
- Testado e passou. User criou Ana, pagou os R$ 800 em dinheiro pelo
  Financeiro. Borda verde + popover "Pago R$ 800" funcionando.

Fase 6 (lock-edit cobrada) ativada em Melissa
- Removido guard `if (!props.occurrenceMode) return;` em
  loadOccFinancialRecord (useAgendaEventLifecycle.js:217+). Agora ele
  carrega em ambos modos (Rail/Clínica E Melissa)
- loadOccFinancialRecord SINTETIZA record paid/pending pra siblings de
  contrato upfront ativo — assim TODAS as ocorrências da série mostram
  "Cobrança paga R$ 800 do pacote" no AgendaEventDialog
- AgendaEventDialog card Sessão/Honorários (flow Melissa) ganhou lock
  template: Tag em vez de Select billingType quando occFinancialRecord
  existe; Message com cadeado "Cobrança de R$ X já emitida"
- AgendaEventoFinanceiroPanel só renderiza dentro do lock quando record
  é REAL (não sintetizado) — evita "Gerar cobrança" indevido em sibling
- paymentSummary do Resumo lateral unificado pra usar occFinancialRecord
  (em vez do sessionPaymentRecord paralelo de antes)

Cross-week propagation de pacote upfront
- BUG: ao navegar pra semana só com virtuais (sem reais), bulk-load
  caía no else `_rulePaymentMap.value = {}` — virtuais perdiam estado
  paid herdado
- FIX em useMelissaAgenda._reloadRange:
  * Maps (payment/amount/rule) inicializados SEMPRE no início
  * Propagação roda independente de realIds.length (depende só de
    ruleIdsInView.size>0, considera reais E virtuais com recurrence_id)
  * Query cross-week: pra cada rule em view, busca QUALQUER evento
    sibling em qualquer semana + seus records pra determinar estado do
    contrato. Encontra o record do pacote mesmo em outra semana
- Saldo NÃO propaga (filter: charging_style='upfront' || NULL); cada
  sessão de saldo gera cobrança individual ao realizar
- Memória durável: memory/project_cross_week_propagation.md

Visualização de virtuais cobertas
- MelissaEventoPanel.showPaymentRow: virtuais só escondem quando state
  ='none'. Com paid/pending herdado, exibem linha colorida
- MelissaAgenda fcEvents: isPaidSession e badge $ pendente removeram
  exigência de !is_occurrence. Virtuais herdadas via propagação mostram
  borda verde / badge amber

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" quando paymentVariant
  ='none' && !is_occurrence. Click → gerarCobrancaManual direto, fecha
  popover pra impedir double-click. Tooltip: "Gerar fatura agora"
- Wire em MelissaLayout via novo emit gerar-cobranca + handler
  onGerarCobrancaQuick

Info de pacote no popover
- Header agora mostra "Sessão · Pacote · N sessões" (computed
  seriesLabel lê de _raw do rule)

Botão "Excluir série inteira"
- Novo emit delete-series em MelissaEventoPanel + botão ao lado de
  "Excluir sessão" quando evento tem recurrence_id
- Handler onDeleteSeries em MelissaLayout: hard delete em 3 etapas
  (financial_records pendentes → agenda_eventos materializados →
  recurrence_rules CASCADE leva exceptions). Bloqueia se algum record
  paid (estorno via Financeiro primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception.type=
  'cancel_session' (era visível com status cancelado; doc dizia que
  some). patient_missed/therapist_canceled/holiday_block permanecem
  como histórico

recurrence_exceptions cancel idempotente
- MelissaLayout onDeleteEvento usa upsert com onConflict pra exception
  cancel — não quebra mais com unique violation em re-cancel

billing_contract_id na 1ª materializada
- _createPackageContract agora .select() o contrato após insert e seta
  billing_contract_id no insert da 1ª agenda_eventos materializada

onVerLancamentos cobre virtual de upfront
- Antes virtual sempre toast "Sem lançamentos". Agora busca records via
  siblings da série pra encontrar o do pacote. Saldo/sem pacote continua
  com toast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 20:54:23 -03:00
parent c23d0a574f
commit 1feb7112ff
8 changed files with 530 additions and 95 deletions
@@ -965,12 +965,15 @@ const occBillingStatusSeverity = computed(() => {
// da cobrança (Pago / Pendente / Atrasada / Sem cobrança). Espelha
// os 3 canais visuais da agenda (verde pago / amber pendente /
// neutro). Sources:
// - sessionPaymentRecord (1 query em useAgendaEventLifecycle)
// - occFinancialRecord (lifecycle, agora carrega em ambos modos —
// guard de occurrenceMode foi removido em 2026-05-19 pra ativar
// lock-edit em Melissa tambem). Cobre record direto OU sintetizado
// a partir de contrato upfront ativo.
// - eventRow.price (fallback pra "Sem cobrança · R$ X" quando
// ainda nao ha record)
const paymentSummary = computed(() => {
if (!isSessionEvent.value) return null;
const rec = sessionPaymentRecord.value;
const rec = occFinancialRecord.value;
const fmtDate = (d) => {
if (!d) return '';
try {
@@ -1770,16 +1773,22 @@ onBeforeUnmount(() => {
/>
</div>
<div class="field-card__body">
<!-- LOCKED: cobranca ja emitida -->
<!-- LOCKED: cobranca ja emitida.
AgendaEventoFinanceiroPanel renderiza quando
o record é REAL (não sintetizado de contrato
upfront em sibling). Sintetizado: a sessão não
tem record próprio, então o panel exibiria
"Gerar cobrança" indevidamente. O lock Message
comunica o estado. -->
<template v-if="occFinancialRecord">
<Message severity="info" :closable="false" class="m-2">
<div class="text-sm">
<i class="pi pi-lock mr-1" />
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> emitida.
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
</template>
<!-- LOADING -->
@@ -2204,7 +2213,13 @@ onBeforeUnmount(() => {
<!-- Mobile: label visível; Desktop: label some e Select
ocupa o espaço. Mesmo padrão do Paciente e Data/Horário. -->
<span class="md:hidden">Sessão / Honorários</span>
<!-- Lock-edit (Fase 6, ativada em Melissa 2026-05-19):
quando cobrança paga/pendente OU contrato upfront
ativo, mostra Tag em vez do Select pra impedir
troca de billingType. -->
<Tag v-if="occFinancialRecord" :value="occBillingStatusLabel" :severity="occBillingStatusSeverity" class="ml-auto" />
<Select
v-else-if="!occFinancialLoading"
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
@@ -2213,7 +2228,20 @@ onBeforeUnmount(() => {
class="aed-pay-mod-select ml-auto md:ml-0"
/>
</div>
<Transition name="aed-pay-expand">
<!-- LOCKED: cobrança emitida (real OU sintetizada via
contrato upfront pago/pendente). Trava edição de
tipo/serviços/preço. Ajustes via Financeiro. -->
<template v-if="occFinancialRecord">
<Message severity="info" :closable="false" class="m-2">
<div class="text-sm">
<i class="pi pi-lock mr-1" />
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
</template>
<Transition v-else-if="!occFinancialLoading" name="aed-pay-expand">
<div class="field-card__body aed-pay-body">
<!-- Gratuito: aviso informativo, sem ações -->
<div v-if="billingType === 'gratuito'" class="aed-pay-gratuito">
@@ -2273,10 +2301,15 @@ onBeforeUnmount(() => {
</div>
</Transition>
<!-- Cobrança da sessão (financial_records ja criado)
em edição. Movido pra dentro do Sessao/Honorarios
em 2026-05-11 (antes ficava num side-card separado). -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadSessionPaymentRecord" />
<!-- UNLOCKED: panel pra gerar cobrança inline (atalho do
popover é o caminho principal, mas mantido aqui pra
paridade com flow anterior). aparece sem record. -->
<AgendaEventoFinanceiroPanel
v-if="!occFinancialRecord && !occFinancialLoading && isEdit && eventRow?.id"
:evento="eventRow"
class="m-3"
@cobranca-atualizada="loadOccFinancialRecord"
/>
<!-- Botão "Ver lançamentos" (2026-05-14): abre dialog
listando todos os financial_records vinculados à
@@ -208,23 +208,89 @@ export function useAgendaEventLifecycle({
}
// ── occurrence financial record loader ────────────────────
// Tenta 3 caminhos pra encontrar um record relevante:
// 1) Record direto por agenda_evento_id (materializada com cobrança própria)
// 2) Se a sessão pertence a contrato upfront PAGO, sintetiza um record
// 'paid' com o package_price → assim TODAS as ocorrências da série
// mostram "Cobrança paga" no dialog (cobertas pelo pacote pago).
// 3) Senão, null → card unlocked.
async function loadOccFinancialRecord() {
occFinancialRecord.value = null;
if (!props.occurrenceMode) return;
// Guard de occurrenceMode REMOVIDO em 2026-05-19 — necessario pra
// que o lock de "edit cobrada" (Fase 6) tambem ative em Melissa,
// nao so em Rail/Clinica. Padrao SimplePractice: cobranca emitida
// eh imutavel pelo dialog; ajustes via estorno no Financeiro.
const evId = props.eventRow?.id;
if (!evId) return;
const ruleId = props.eventRow?.recurrence_id;
const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
occFinancialLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
occFinancialRecord.value = data ?? null;
// 1) Record direto (materializada que tem agenda_evento_id real)
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
if (evId && !isVirtualId) {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
if (data) {
occFinancialRecord.value = data;
return;
}
}
// 2) Sintetiza record 'paid' quando há contrato upfront pago.
// A 1ª materializada tem o record real; siblings (virtuais ou
// materializadas sem cobrança individual) herdam status do
// contrato pra UI mostrar "Cobrança paga" coerentemente.
if (ruleId && patientId) {
const { data: contracts } = await supabase
.from('billing_contracts')
.select('id, package_price, charging_style, status')
.eq('patient_id', patientId)
.eq('type', 'package')
.eq('status', 'active')
.order('created_at', { ascending: false });
const upfront = (contracts || []).find((c) => c.charging_style === 'upfront');
if (upfront) {
// Confere se há record PAGO ligado a qualquer evento do
// mesmo recurrence_id (ou seja, contrato foi quitado).
const { data: siblingEvents } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
const ids = (siblingEvents || []).map((e) => e.id);
if (ids.length) {
// Pega o record mais recente NAO cancelado (paid OU
// pending OU overdue). Pacote upfront tem 1 record
// unico cobrindo toda a serie — qualquer status dele
// trava as siblings (cobranca ja emitida, imutavel).
const { data: anyRec } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.in('agenda_evento_id', ids)
.in('status', ['paid', 'pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (anyRec) {
// Sintetiza usando o package_price (não o per-session
// que pode ter mudado depois de editar).
occFinancialRecord.value = {
...anyRec,
amount: upfront.package_price,
final_amount: upfront.package_price,
_synthesized: true
};
return;
}
}
}
}
occFinancialRecord.value = null;
} catch (e) {
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
occFinancialRecord.value = null;