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
@@ -43,14 +43,17 @@ import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: { type: Boolean, default: false },
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
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
pendingRecord: { type: Object, default: null },
// 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']);
@@ -62,6 +65,11 @@ const fineAmount = ref(0);
const markPaid = ref(true); // default em realizado: já recebeu
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
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
watch(
@@ -83,16 +91,41 @@ watch(
// troca pra "Sim, já recebi", precisa escolher PIX/Dinheiro/etc.
paymentMethod.value = 'pending';
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 ────────────────────────────────────────
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
const isRealizado = computed(() => props.novoStatus === 'realizado');
const isReverseAgendado = computed(() => props.novoStatus === 'agendado');
const isAvulsa = computed(() => !props.billingContract);
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
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'
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
@@ -107,7 +140,12 @@ const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.val
// ─── Header ────────────────────────────────────────────────────────────
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';
});
@@ -224,7 +262,10 @@ function onConfirm() {
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
markPaid: considerMarkPaid ? markPaid.value : false,
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);
}
@@ -259,6 +300,67 @@ function onCancel() {
</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) 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) -->
<div v-if="showSaldoBlock" class="asccd-block">
<div class="asccd-block__title">
@@ -532,4 +634,15 @@ function onCancel() {
color: var(--text-color-secondary);
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>