diff --git a/src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue b/src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
index fd273ab..36f14ae 100644
--- a/src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
+++ b/src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
@@ -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() {
+
+
+
+
+ Reverter status de {{ reversePreviousStatusLabel }} pra Agendada
+
+
Esta sessão tem ações financeiras vinculadas. Escolha o que fazer com cada uma antes de reverter:
+
+
+
+
+
+ Cobrança pendente vinculada
+
+
+
+ {{ r.description || 'Cobrança' }}
+ · R$ {{ _fmtBRL(r.final_amount || r.amount) }}
+
+
+
+
+
+ Cancelar a(s) cobrança(s) — recomendado
+
+
+
+ Manter ativa(s) — sessão volta agendada mas cobrança continua aberta
+
+
+
+
+
+
+
+
+ Atenção: 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.
+
+
+
+
+
+
+
+ Saldo do pacote consumido
+
+
Saldo atual: {{ billingContract?.sessions_used ?? '?' }} de {{ billingContract?.total_sessions ?? '?' }} usadas
+
+
+
+ Devolver 1 sessão ao saldo — recomendado
+
+
+
+ Manter saldo consumido — sessão volta agendada mas saldo continua decrementado
+
+
+
+
+
@@ -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;
+}
diff --git a/src/layout/melissa/composables/useMelissaAgenda.js b/src/layout/melissa/composables/useMelissaAgenda.js
index 1302027..620b35a 100644
--- a/src/layout/melissa/composables/useMelissaAgenda.js
+++ b/src/layout/melissa/composables/useMelissaAgenda.js
@@ -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.