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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user