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
@@ -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;