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) }} +
  • +
+
+ + +
+
+ + +
+ +
+ 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
+
+ + +
+
+
+
@@ -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.