agenda: reverse transition trava (Agendada apos artefatos)

User hit pra valer a pendencia documentada (reverter
realizado/faltou/cancelado pra agendado deixa records/saldo
orfaos). Decidido implementar trava AGORA em vez de pos-C13.

Quando user clica "Agendada" no popover/dialog em sessao que
tem artefatos pendentes (cobranca pending, multa, saldo consumido
em pacote saldo), abre o AgendaStatusChangeConfirmDialog com nova
variante "reverse":

1. Lista records pending vinculados (descricao + valor) com radio
   [Cancelar (recomendado) | Manter ativa]
2. Warning textual pra records PAID (estorno e manual pelo
   Financeiro — sem radio, so info)
3. Saldo consumido (pacote saldo): radio [Devolver 1 sessao | Manter]

No confirm:
- Cancela records pending escolhidos (status='cancelled' + notes
  de auditoria)
- Decrementa sessions_used + reativa contract se estava completed
- Desamarra billing_contract_id do evento se devolveu saldo
- Status muda pra agendado (ja foi aplicado pelo _applyStatusUpdateOnly)

Se nao tem artefato algum (sessao agendado -> agendado, ou
realizado sem records): aplica direto sem dialog (existing
behavior via _needsConfirmDialog).

_loadStatusChangeContext agora carrega reverseArtifacts (status
anterior, records ativos, saldoConsumed) quando novoStatus=agendado.

Memoria project_agenda_reverse_transitions atualizada — pendencia
fechada antes da hora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-20 11:40:19 -03:00
parent 16dfa02bd1
commit 5684297243
2 changed files with 241 additions and 8 deletions
@@ -1216,8 +1216,12 @@ function _buildHandlers(deps) {
async function onUpdateSeriesEvent(arg) {
const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
try {
const needsDialog = ['realizado', 'faltou', 'cancelado'].includes(status);
if (!needsDialog) {
// realizado/faltou/cancelado abrem dialog forward.
// agendado (reverse transition) abre dialog se houver artefatos
// pendentes a desfazer: cobrança pendente, multa, saldo consumido.
const isForward = ['realizado', 'faltou', 'cancelado'].includes(status);
const isReverse = status === 'agendado';
if (!isForward && !isReverse) {
await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
return;
}
@@ -1239,7 +1243,8 @@ function _buildHandlers(deps) {
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style ?? null,
pendingRecord: ctx.pendingRecord,
sessionPrice: Number(row.price ?? 0)
sessionPrice: Number(row.price ?? 0),
reverseArtifacts: ctx.reverseArtifacts || null
});
if (!decision) {
// User cancelou — reverte status no form do AgendaEventDialog
@@ -1410,6 +1415,46 @@ function _buildHandlers(deps) {
}
}
// 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;
}
@@ -1418,6 +1463,7 @@ function _buildHandlers(deps) {
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';
@@ -1431,16 +1477,90 @@ function _buildHandlers(deps) {
// 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;
}
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote).
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
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)
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const newUsed = Math.max(0, (ctx.billingContract.sessions_used ?? 0) - 1);
const patch = { sessions_used: newUsed };
// Se contrato estava 'completed' (atingiu total) e voltou abaixo, reativa.
if ((ctx.billingContract.sessions_used ?? 0) >= (ctx.billingContract.total_sessions ?? 0)) {
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('[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.