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:
@@ -43,14 +43,17 @@ import { ref, computed, watch } from 'vue';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
evento: { type: Object, default: null },
|
evento: { type: Object, default: null },
|
||||||
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado'
|
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado' | 'agendado' (reverse)
|
||||||
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
|
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
|
||||||
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
|
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
|
||||||
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
|
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
|
||||||
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
|
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
|
||||||
pendingRecord: { type: Object, default: null },
|
pendingRecord: { type: Object, default: null },
|
||||||
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
|
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
|
||||||
sessionPrice: { type: Number, default: 0 }
|
sessionPrice: { type: Number, default: 0 },
|
||||||
|
// Reverse transition (novoStatus='agendado'): artefatos a desfazer.
|
||||||
|
// { previousStatus, activeRecords[], saldoConsumed }
|
||||||
|
reverseArtifacts: { type: Object, default: null }
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||||
@@ -62,6 +65,11 @@ const fineAmount = ref(0);
|
|||||||
const markPaid = ref(true); // default em realizado: já recebeu
|
const markPaid = ref(true); // default em realizado: já recebeu
|
||||||
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
|
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
|
||||||
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
|
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
|
||||||
|
// ─── Reverse transition state (novoStatus='agendado') ──────────────────
|
||||||
|
// Decisões pra desfazer ao reverter pra agendado.
|
||||||
|
const reverseCancelPending = ref(true); // cancela records pending/overdue
|
||||||
|
const reverseRestoreSaldo = ref(true); // devolve 1 ao saldo do pacote
|
||||||
|
// Records 'paid' não têm radio — só warning textual (estorno é manual).
|
||||||
|
|
||||||
// Reset/init ao abrir
|
// Reset/init ao abrir
|
||||||
watch(
|
watch(
|
||||||
@@ -83,16 +91,41 @@ watch(
|
|||||||
// troca pra "Sim, já recebi", precisa escolher PIX/Dinheiro/etc.
|
// troca pra "Sim, já recebi", precisa escolher PIX/Dinheiro/etc.
|
||||||
paymentMethod.value = 'pending';
|
paymentMethod.value = 'pending';
|
||||||
generatePackageCharge.value = true;
|
generatePackageCharge.value = true;
|
||||||
|
// Reverse transition: defaults safer = cancela records pendentes +
|
||||||
|
// devolve saldo (typical recovery flow). Records paid não têm radio.
|
||||||
|
reverseCancelPending.value = true;
|
||||||
|
reverseRestoreSaldo.value = true;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Computeds: o que renderizar ────────────────────────────────────────
|
// ─── Computeds: o que renderizar ────────────────────────────────────────
|
||||||
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
|
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
|
||||||
const isRealizado = computed(() => props.novoStatus === 'realizado');
|
const isRealizado = computed(() => props.novoStatus === 'realizado');
|
||||||
|
const isReverseAgendado = computed(() => props.novoStatus === 'agendado');
|
||||||
const isAvulsa = computed(() => !props.billingContract);
|
const isAvulsa = computed(() => !props.billingContract);
|
||||||
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
|
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
|
||||||
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
|
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
|
||||||
|
|
||||||
|
// Reverse transition: derivados pra renderizar blocos
|
||||||
|
const reversePendingRecords = computed(() => {
|
||||||
|
const recs = props.reverseArtifacts?.activeRecords || [];
|
||||||
|
return recs.filter((r) => r.status === 'pending' || r.status === 'overdue');
|
||||||
|
});
|
||||||
|
const reversePaidRecords = computed(() => {
|
||||||
|
const recs = props.reverseArtifacts?.activeRecords || [];
|
||||||
|
return recs.filter((r) => r.status === 'paid');
|
||||||
|
});
|
||||||
|
const reverseHasPaid = computed(() => reversePaidRecords.value.length > 0);
|
||||||
|
const reverseHasPending = computed(() => reversePendingRecords.value.length > 0);
|
||||||
|
const reverseShowSaldo = computed(() => isReverseAgendado.value && !!props.reverseArtifacts?.saldoConsumed);
|
||||||
|
const reversePreviousStatusLabel = computed(() => {
|
||||||
|
const s = props.reverseArtifacts?.previousStatus;
|
||||||
|
if (s === 'realizado') return 'Realizada';
|
||||||
|
if (s === 'faltou') return 'Faltou';
|
||||||
|
if (s === 'cancelado') return 'Cancelado';
|
||||||
|
return s || 'estado anterior';
|
||||||
|
});
|
||||||
|
|
||||||
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
|
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
|
||||||
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
|
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
|
||||||
|
|
||||||
@@ -107,7 +140,12 @@ const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.val
|
|||||||
|
|
||||||
// ─── Header ────────────────────────────────────────────────────────────
|
// ─── Header ────────────────────────────────────────────────────────────
|
||||||
const headerTitle = computed(() => {
|
const headerTitle = computed(() => {
|
||||||
const labels = { realizado: '✓ Marcar como Realizado', faltou: '⚠ Marcar como Faltou', cancelado: '✕ Marcar como Cancelado' };
|
const labels = {
|
||||||
|
realizado: '✓ Marcar como Realizado',
|
||||||
|
faltou: '⚠ Marcar como Faltou',
|
||||||
|
cancelado: '✕ Marcar como Cancelado',
|
||||||
|
agendado: '↺ Reativar sessão (voltar pra Agendada)'
|
||||||
|
};
|
||||||
return labels[props.novoStatus] || 'Atualizar status';
|
return labels[props.novoStatus] || 'Atualizar status';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +262,10 @@ function onConfirm() {
|
|||||||
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
|
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
|
||||||
markPaid: considerMarkPaid ? markPaid.value : false,
|
markPaid: considerMarkPaid ? markPaid.value : false,
|
||||||
paymentMethod: paymentMethod.value,
|
paymentMethod: paymentMethod.value,
|
||||||
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false
|
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false,
|
||||||
|
// Reverse transition: só relevante quando novoStatus='agendado'
|
||||||
|
reverseCancelPending: isReverseAgendado.value ? reverseCancelPending.value : false,
|
||||||
|
reverseRestoreSaldo: isReverseAgendado.value ? reverseRestoreSaldo.value : false
|
||||||
});
|
});
|
||||||
emit('update:modelValue', false);
|
emit('update:modelValue', false);
|
||||||
}
|
}
|
||||||
@@ -259,6 +300,67 @@ function onCancel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Reverse transition: voltar pra Agendada (com artefatos) ── -->
|
||||||
|
<div v-if="isReverseAgendado" class="asccd-block">
|
||||||
|
<div class="asccd-block__title">
|
||||||
|
<i class="pi pi-info-circle" />
|
||||||
|
Reverter status de <b>{{ reversePreviousStatusLabel }}</b> pra <b>Agendada</b>
|
||||||
|
</div>
|
||||||
|
<small class="asccd-hint">Esta sessão tem ações financeiras vinculadas. Escolha o que fazer com cada uma antes de reverter:</small>
|
||||||
|
|
||||||
|
<!-- Records pendentes -->
|
||||||
|
<div v-if="reverseHasPending" class="asccd-block mt-3">
|
||||||
|
<div class="asccd-block__title">
|
||||||
|
<i class="pi pi-money-bill" />
|
||||||
|
Cobrança pendente vinculada
|
||||||
|
</div>
|
||||||
|
<ul class="asccd-list">
|
||||||
|
<li v-for="r in reversePendingRecords" :key="r.id">
|
||||||
|
<span class="font-medium">{{ r.description || 'Cobrança' }}</span>
|
||||||
|
<span> · R$ {{ _fmtBRL(r.final_amount || r.amount) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="asccd-radio-group mt-2">
|
||||||
|
<label class="asccd-radio">
|
||||||
|
<input type="radio" :value="true" v-model="reverseCancelPending" />
|
||||||
|
<span><b>Cancelar</b> a(s) cobrança(s) — recomendado</span>
|
||||||
|
</label>
|
||||||
|
<label class="asccd-radio">
|
||||||
|
<input type="radio" :value="false" v-model="reverseCancelPending" />
|
||||||
|
<span><b>Manter</b> ativa(s) — sessão volta agendada mas cobrança continua aberta</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Records pagos: warning sem ação -->
|
||||||
|
<div v-if="reverseHasPaid" class="asccd-info mt-3">
|
||||||
|
<i class="pi pi-exclamation-triangle" />
|
||||||
|
<div>
|
||||||
|
<b>Atenção:</b> Esta sessão tem cobrança(s) já paga(s) ({{ reversePaidRecords.length }} record(s)).
|
||||||
|
Reverter o status NÃO estorna o pagamento automaticamente — pra estornar use o /financeiro.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saldo consumido em pacote -->
|
||||||
|
<div v-if="reverseShowSaldo" class="asccd-block mt-3">
|
||||||
|
<div class="asccd-block__title">
|
||||||
|
<i class="pi pi-wallet" />
|
||||||
|
Saldo do pacote consumido
|
||||||
|
</div>
|
||||||
|
<div class="asccd-fine-rule">Saldo atual: {{ billingContract?.sessions_used ?? '?' }} de {{ billingContract?.total_sessions ?? '?' }} usadas</div>
|
||||||
|
<div class="asccd-radio-group mt-2">
|
||||||
|
<label class="asccd-radio">
|
||||||
|
<input type="radio" :value="true" v-model="reverseRestoreSaldo" />
|
||||||
|
<span><b>Devolver</b> 1 sessão ao saldo — recomendado</span>
|
||||||
|
</label>
|
||||||
|
<label class="asccd-radio">
|
||||||
|
<input type="radio" :value="false" v-model="reverseRestoreSaldo" />
|
||||||
|
<span><b>Manter</b> saldo consumido — sessão volta agendada mas saldo continua decrementado</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Bloco SALDO (pacote saldo + faltou/cancelado) ────────── -->
|
<!-- ─── Bloco SALDO (pacote saldo + faltou/cancelado) ────────── -->
|
||||||
<div v-if="showSaldoBlock" class="asccd-block">
|
<div v-if="showSaldoBlock" class="asccd-block">
|
||||||
<div class="asccd-block__title">
|
<div class="asccd-block__title">
|
||||||
@@ -532,4 +634,15 @@ function onCancel() {
|
|||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asccd-list {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.asccd-list li {
|
||||||
|
list-style: disc;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1216,8 +1216,12 @@ function _buildHandlers(deps) {
|
|||||||
async function onUpdateSeriesEvent(arg) {
|
async function onUpdateSeriesEvent(arg) {
|
||||||
const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
|
const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
|
||||||
try {
|
try {
|
||||||
const needsDialog = ['realizado', 'faltou', 'cancelado'].includes(status);
|
// realizado/faltou/cancelado abrem dialog forward.
|
||||||
if (!needsDialog) {
|
// 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 });
|
await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1239,7 +1243,8 @@ function _buildHandlers(deps) {
|
|||||||
billingContract: ctx.billingContract,
|
billingContract: ctx.billingContract,
|
||||||
billingContractStyle: ctx.billingContract?.charging_style ?? null,
|
billingContractStyle: ctx.billingContract?.charging_style ?? null,
|
||||||
pendingRecord: ctx.pendingRecord,
|
pendingRecord: ctx.pendingRecord,
|
||||||
sessionPrice: Number(row.price ?? 0)
|
sessionPrice: Number(row.price ?? 0),
|
||||||
|
reverseArtifacts: ctx.reverseArtifacts || null
|
||||||
});
|
});
|
||||||
if (!decision) {
|
if (!decision) {
|
||||||
// User cancelou — reverte status no form do AgendaEventDialog
|
// 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;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1418,6 +1463,7 @@ function _buildHandlers(deps) {
|
|||||||
function _needsConfirmDialog(status, ctx) {
|
function _needsConfirmDialog(status, ctx) {
|
||||||
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
||||||
const isRealizado = status === 'realizado';
|
const isRealizado = status === 'realizado';
|
||||||
|
const isAgendado = status === 'agendado';
|
||||||
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
||||||
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
|
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
|
||||||
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
|
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)
|
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
|
||||||
return hasPending || isPacoteSaldo;
|
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;
|
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 }) {
|
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
|
||||||
const tenantId = clinicTenantId.value;
|
const tenantId = clinicTenantId.value;
|
||||||
const uid = ownerId.value;
|
const uid = ownerId.value;
|
||||||
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
||||||
const tasks = [];
|
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)
|
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
|
||||||
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
|
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
|
||||||
// causa "column does not exist" silenciosamente em Promise.allSettled.
|
// causa "column does not exist" silenciosamente em Promise.allSettled.
|
||||||
|
|||||||
Reference in New Issue
Block a user