+
+
+ Modelo saldo: cada sessão gera cobrança ao ser marcada como realizada. Use "Usar agora" pra consumir 1 do saldo (marca realizada + gera fatura de {{ fmtBRL(sessionContractInfo.perSession) }}).
+
+
+ Modelo upfront: todas as sessões foram cobradas de uma vez no início do pacote.
+
+
+
+
+
@@ -3635,6 +3734,104 @@ onBeforeUnmount(() => {
color: var(--text-color);
}
+/* Info card de pacote (saldo/upfront) — 2026-05-19 noite. Mostra status
+ do contrato quando sessão pertence a série com billing_contract ativo.
+ Saldo: tom violeta (contexto, modelo Cliniko). Upfront: tom verde
+ (já cobrado, status positivo). */
+.aed-contract-card {
+ border-radius: 10px;
+ border: 1px solid;
+ overflow: hidden;
+ font-size: 0.82rem;
+}
+.aed-contract-card--saldo {
+ background: color-mix(in srgb, rgb(124, 58, 237) 6%, var(--surface-card));
+ border-color: color-mix(in srgb, rgb(124, 58, 237) 30%, transparent);
+}
+.aed-contract-card--upfront {
+ background: color-mix(in srgb, rgb(16, 185, 129) 6%, var(--surface-card));
+ border-color: color-mix(in srgb, rgb(16, 185, 129) 30%, transparent);
+}
+.aed-contract-card__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0.55rem 0.85rem;
+ border-bottom: 1px solid color-mix(in srgb, var(--surface-border), transparent 30%);
+ font-weight: 600;
+}
+.aed-contract-card--saldo .aed-contract-card__head > i {
+ color: rgb(124, 58, 237);
+}
+.aed-contract-card--upfront .aed-contract-card__head > i {
+ color: rgb(16, 185, 129);
+}
+.aed-contract-card__title {
+ flex: 1;
+}
+.aed-contract-card__count {
+ font-size: 0.74rem;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+.aed-contract-card__body {
+ padding: 0.7rem 0.85rem 0.85rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.aed-contract-card__row {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 0.78rem;
+}
+.aed-contract-card__label {
+ color: var(--text-color-secondary);
+}
+.aed-contract-card__value {
+ font-weight: 600;
+ color: var(--text-color);
+}
+.aed-contract-card__hint {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px dashed color-mix(in srgb, var(--surface-border), transparent 30%);
+ font-size: 0.74rem;
+ color: var(--text-color-secondary);
+ line-height: 1.4;
+}
+.aed-contract-card__hint > i {
+ margin-top: 1px;
+ flex-shrink: 0;
+}
+.aed-contract-card--saldo .aed-contract-card__hint > i {
+ color: rgb(124, 58, 237);
+}
+.aed-contract-card--upfront .aed-contract-card__hint > i {
+ color: rgb(16, 185, 129);
+}
+.aed-contract-card__hint b {
+ color: var(--text-color);
+}
+.aed-contract-card__loading {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 0.5rem;
+ font-size: 0.74rem;
+ color: var(--text-color-secondary);
+}
+.aed-contract-card__loading > i {
+ color: var(--p-primary-color);
+}
+
/* Padding interno do card "Campos Extras (compromisso)" — mesmo
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
bordas. */
@@ -5195,12 +5392,42 @@ onBeforeUnmount(() => {
display: flex;
gap: 0.3rem;
font-size: 0.7rem;
- font-weight: 400;
+ font-weight: 500;
text-transform: none;
letter-spacing: 0;
- opacity: 0.75;
margin-left: 0.25rem;
}
+/* Contadores coloridos por estado no header de Recorrências Aplicadas */
+.serie-stat--total {
+ color: rgb(37, 99, 235); /* blue-600 */
+}
+.serie-stat--realizado {
+ color: rgb(5, 150, 105); /* emerald-600 */
+}
+.serie-stat--faltou {
+ color: rgb(217, 119, 6); /* amber-600 */
+}
+.serie-stat--cancelado {
+ color: rgb(120, 113, 108); /* stone-500 */
+}
+.serie-stat--remarcado {
+ color: rgb(124, 58, 237); /* violet-600 */
+}
+html.app-dark .serie-stat--total {
+ color: rgb(96, 165, 250); /* blue-400 */
+}
+html.app-dark .serie-stat--realizado {
+ color: rgb(52, 211, 153); /* emerald-400 */
+}
+html.app-dark .serie-stat--faltou {
+ color: rgb(251, 191, 36); /* amber-400 */
+}
+html.app-dark .serie-stat--cancelado {
+ color: rgb(168, 162, 158); /* stone-400 */
+}
+html.app-dark .serie-stat--remarcado {
+ color: rgb(167, 139, 250); /* violet-400 */
+}
.serie-panel__empty {
padding: 0.85rem;
font-size: 0.82rem;
@@ -5310,6 +5537,47 @@ onBeforeUnmount(() => {
overflow: hidden;
text-overflow: ellipsis;
}
+/* Badge sólido por status quando relevante (realizado/faltou/cancelado/
+ remarcado). Agendado fica neutro (default acima). */
+.serie-pill--realizado .serie-pill__status-badge,
+.serie-pill--realizada .serie-pill__status-badge {
+ flex: 0 0 auto;
+ color: white;
+ background: rgb(5, 150, 105); /* emerald-600 */
+ padding: 0.15rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+}
+.serie-pill--faltou .serie-pill__status-badge {
+ flex: 0 0 auto;
+ color: white;
+ background: rgb(217, 119, 6); /* amber-600 */
+ padding: 0.15rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+}
+.serie-pill--cancelado .serie-pill__status-badge,
+.serie-pill--cancelada .serie-pill__status-badge {
+ flex: 0 0 auto;
+ color: white;
+ background: rgb(120, 113, 108); /* stone-500 */
+ padding: 0.15rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+}
+.serie-pill--remarcado .serie-pill__status-badge,
+.serie-pill--remarcada .serie-pill__status-badge {
+ flex: 0 0 auto;
+ color: white;
+ background: rgb(124, 58, 237); /* violet-600 */
+ padding: 0.15rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+}
.serie-pill__cur-badge {
font-size: 0.58rem;
font-weight: 700;
diff --git a/src/features/agenda/composables/useAgendaEventLifecycle.js b/src/features/agenda/composables/useAgendaEventLifecycle.js
index 3214bac..3f8a7d9 100644
--- a/src/features/agenda/composables/useAgendaEventLifecycle.js
+++ b/src/features/agenda/composables/useAgendaEventLifecycle.js
@@ -112,6 +112,14 @@ export function useAgendaEventLifecycle({
// continua via occFinancialRecord (território da Fase 6/C13).
const sessionPaymentRecord = ref(null);
+ // sessionContract (2026-05-19 noite): billing_contract ativo do paciente
+ // quando a sessão pertence a uma série com pacote (upfront ou saldo).
+ // Usado pra exibir info card no dialog explicando o pacote (qtd
+ // sessões usadas/restantes, valor, comportamento). Pra saldo, é a
+ // única forma do user entender o que tá acontecendo (nada aparece
+ // no /financeiro até realizar sessão).
+ const sessionContract = ref(null);
+
// ── computeds locais ───────────────────────────────────────
const serieCountByStatus = computed(() => {
const counts = {};
@@ -323,6 +331,33 @@ export function useAgendaEventLifecycle({
}
}
+ // loadSessionContract (2026-05-19 noite): busca billing_contract ativo
+ // do paciente quando o evento pertence a uma série com pacote. Usado
+ // pra info card no dialog explicando o pacote saldo/upfront.
+ async function loadSessionContract() {
+ sessionContract.value = null;
+ const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
+ const ruleId = props.eventRow?.recurrence_id;
+ // Só faz sentido pra sessão de série
+ if (!patientId || !ruleId) return;
+ try {
+ const { data, error } = await supabase
+ .from('billing_contracts')
+ .select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from')
+ .eq('patient_id', patientId)
+ .eq('type', 'package')
+ .eq('status', 'active')
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ if (error) throw error;
+ sessionContract.value = data ?? null;
+ } catch (e) {
+ console.warn('[session-contract] erro ao carregar:', e?.message);
+ sessionContract.value = null;
+ }
+ }
+
function onPillEditClick(ev) {
emit('editSeriesOccurrence', {
id: ev.id,
@@ -508,6 +543,7 @@ export function useAgendaEventLifecycle({
// sessionPaymentRecord: carrega em qualquer edit (Melissa
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
loadSessionPaymentRecord();
+ loadSessionContract();
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
@@ -628,6 +664,8 @@ export function useAgendaEventLifecycle({
loadSerieEvents,
loadOccFinancialRecord,
loadSessionPaymentRecord,
+ sessionContract,
+ loadSessionContract,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
diff --git a/src/layout/melissa/MelissaEventoPanel.vue b/src/layout/melissa/MelissaEventoPanel.vue
index c4eaa66..4fa00c8 100644
--- a/src/layout/melissa/MelissaEventoPanel.vue
+++ b/src/layout/melissa/MelissaEventoPanel.vue
@@ -38,7 +38,9 @@ const emit = defineEmits([
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
- 'gerar-cobranca' // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
+ 'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
+ 'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
+ 'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
]);
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
@@ -74,6 +76,31 @@ const seriesLabel = computed(() => {
return 'Série recorrente';
});
+// Info de contrato (saldo/upfront) — vem injetada pelo bulk-load via
+// _ruleContractMap. Mostra linha no popover indicando o tipo de pacote
+// e progresso (usadas/total). Pra saldo é especialmente importante —
+// sem essa linha o user não sabe o que está acontecendo (nenhuma
+// cobrança no /financeiro até realizar).
+const contractInfo = computed(() => {
+ const c = ev.value.contract;
+ if (!c) return null;
+ const total = c.totalSessions || 0;
+ const used = c.sessionsUsed || 0;
+ const remaining = Math.max(0, total - used);
+ const perSession = total > 0 ? (c.packagePrice || 0) / total : 0;
+ return {
+ style: c.style || 'upfront',
+ total,
+ used,
+ remaining,
+ packagePrice: c.packagePrice || 0,
+ perSession,
+ label: c.style === 'saldo'
+ ? `Pacote saldo · ${used}/${total} usadas`
+ : `Pacote · ${used}/${total} realizadas`
+ };
+});
+
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
@@ -250,8 +277,13 @@ function modalidadeIcon(mod) {
com paymentState='none' (cobrança ainda não gerada).
Pago/pendente já existe um record; nesses casos não
cabe gerar de novo. -->
+
+
+
+
+ {{ contractInfo.label }}
+
+
+
+
+
+
{{ ev.modalidade }}
@@ -607,6 +677,68 @@ html.app-dark .evento-row__pay-action {
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
}
+/* Linha de contrato — exibe "Pacote saldo · N/M usadas" ou "Pacote · N/M
+ realizadas". Saldo em violeta (modelo Cliniko), upfront em verde
+ (já cobrado). Acompanha as cores do info card do AgendaEventDialog. */
+.evento-row--contract {
+ font-weight: 500;
+}
+.evento-row--contract-saldo {
+ color: rgb(124, 58, 237); /* violet-600 */
+}
+.evento-row--contract-saldo > i {
+ color: rgb(124, 58, 237);
+}
+.evento-row--contract-upfront {
+ color: rgb(5, 150, 105); /* emerald-600 */
+}
+.evento-row--contract-upfront > i {
+ color: rgb(5, 150, 105);
+}
+html.app-dark .evento-row--contract-saldo {
+ color: #a78bfa; /* violet-400 */
+}
+html.app-dark .evento-row--contract-saldo > i {
+ color: #a78bfa;
+}
+html.app-dark .evento-row--contract-upfront {
+ color: #34d399; /* emerald-400 */
+}
+html.app-dark .evento-row--contract-upfront > i {
+ color: #34d399;
+}
+/* Override do pay-action quando dentro da linha de contrato — usa
+ violeta (saldo) pra combinar com a linha. */
+.evento-row__pay-action--contract {
+ color: rgb(124, 58, 237) !important;
+ background: color-mix(in srgb, rgb(124, 58, 237) 12%, transparent) !important;
+ border-color: color-mix(in srgb, rgb(124, 58, 237) 36%, transparent) !important;
+}
+.evento-row__pay-action--contract:hover:not(:disabled) {
+ background: color-mix(in srgb, rgb(124, 58, 237) 22%, transparent) !important;
+ border-color: color-mix(in srgb, rgb(124, 58, 237) 56%, transparent) !important;
+}
+html.app-dark .evento-row__pay-action--contract {
+ color: #a78bfa !important;
+ background: color-mix(in srgb, #a78bfa 14%, transparent) !important;
+ border-color: color-mix(in srgb, #a78bfa 30%, transparent) !important;
+}
+/* Revogar — vermelho/amber pra sinalizar ação destrutiva. */
+.evento-row__pay-action--revogar {
+ color: rgb(220, 38, 38) !important; /* red-600 */
+ background: color-mix(in srgb, rgb(220, 38, 38) 10%, transparent) !important;
+ border-color: color-mix(in srgb, rgb(220, 38, 38) 32%, transparent) !important;
+}
+.evento-row__pay-action--revogar:hover:not(:disabled) {
+ background: color-mix(in srgb, rgb(220, 38, 38) 18%, transparent) !important;
+ border-color: color-mix(in srgb, rgb(220, 38, 38) 50%, transparent) !important;
+}
+html.app-dark .evento-row__pay-action--revogar {
+ color: #f87171 !important; /* red-400 */
+ background: color-mix(in srgb, #f87171 14%, transparent) !important;
+ border-color: color-mix(in srgb, #f87171 30%, transparent) !important;
+}
+
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
Empilhados verticalmente à direita da linha das horas. */
.evento-row__edit-stack {
diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue
index e2eb50a..a46b67b 100644
--- a/src/layout/melissa/MelissaLayout.vue
+++ b/src/layout/melissa/MelissaLayout.vue
@@ -1245,6 +1245,246 @@ async function onGerarCobrancaQuick() {
}
}
+// "Usar" sessão de pacote saldo — combina em uma ação:
+// 1. Materializa virtual (se ainda virtual)
+// 2. Status='realizada'
+// 3. Cria financial_record per_session via RPC (status='pending', dueDate=hoje)
+// 4. Incrementa billing_contracts.sessions_used
+// 5. Se sessions_used == total → contract.status='completed'
+// Reflete o modelo Cliniko: paciente comparece, sessão é "usada" do saldo,
+// nasce a cobrança individual.
+async function onUsarSessao(payload = null) {
+ // Aceita payload do dialog ({ eventRow, contract }) OU usa fallback
+ // do estado do popover (eventoSelecionado + ev.contract injetado pelo
+ // bulk-load). Permite reuse do handler em ambos os fluxos.
+ const ev = payload?.eventRow || eventoSelecionado.value;
+ if (!ev || eventoBusy.value) return;
+ const contract = payload?.contract || ev?.contract;
+ if (!contract?.id) {
+ toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 });
+ return;
+ }
+ if (contract.style !== 'saldo') {
+ // Pra upfront, "Usar" não cabe (pacote já foi pago de uma vez)
+ toast.add({ severity: 'info', summary: 'Pacote upfront', detail: 'Este pacote já foi cobrado integralmente. Use "Realizada" pra marcar status.', life: 4000 });
+ return;
+ }
+
+ eventoBusy.value = true;
+ try {
+ const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
+ const ownerId = M?.ownerId?.value || ev.owner_id;
+ const perSession = contract.totalSessions > 0 ? (contract.packagePrice || 0) / contract.totalSessions : 0;
+ const dueDate = ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : new Date().toISOString().slice(0, 10);
+
+ // 1) Materializa virtual se necessário
+ let eventoId = ev.id;
+ const isVirtualId = typeof eventoId === 'string' && eventoId.startsWith('rec::');
+ if (ev.is_occurrence || isVirtualId) {
+ const recurrenceDate = ev.recurrence_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
+ // Preserva determined_commitment_id da regra (necessário pra
+ // isSessionEvent=true no AgendaEventDialog; sem ele o campo
+ // "Título" aparece indevidamente porque o dialog acha que não
+ // é sessão).
+ const raw = ev._raw || {};
+ const { data: created, error: matErr } = await supabase
+ .from('agenda_eventos')
+ .insert({
+ owner_id: ownerId,
+ tenant_id: tenantId,
+ patient_id: ev.patient_id || ev.paciente_id,
+ recurrence_id: ev.recurrence_id,
+ recurrence_date: recurrenceDate,
+ tipo: 'sessao',
+ status: 'realizado',
+ titulo: ev.label || ev.titulo || raw.titulo || 'Sessão',
+ inicio_em: ev.inicio_em,
+ fim_em: ev.fim_em,
+ modalidade: ev.modalidade || raw.modalidade || 'presencial',
+ determined_commitment_id: raw.determined_commitment_id || null,
+ price: perSession,
+ billing_contract_id: contract.id,
+ visibility_scope: 'public'
+ })
+ .select('id')
+ .single();
+ if (matErr) throw matErr;
+ eventoId = created.id;
+ } else {
+ // Sessão real: atualiza status + garante link com contrato.
+ // Tambem backfilla determined_commitment_id se estiver NULL
+ // (legado de uses antigos antes do fix). Sem isso o dialog
+ // mostra campo "Título" indevidamente (isSessionEvent=false).
+ const raw = ev._raw || {};
+ const patch = { status: 'realizado', billing_contract_id: contract.id };
+ const commitmentId = raw.determined_commitment_id || null;
+ if (commitmentId) {
+ patch.determined_commitment_id = commitmentId;
+ } else {
+ // Busca da rule (fonte autoritativa) se ev não tem
+ const { data: rule } = await supabase
+ .from('recurrence_rules')
+ .select('determined_commitment_id')
+ .eq('id', ev.recurrence_id)
+ .maybeSingle();
+ if (rule?.determined_commitment_id) {
+ patch.determined_commitment_id = rule.determined_commitment_id;
+ }
+ }
+ const { error: upErr } = await supabase
+ .from('agenda_eventos')
+ .update(patch)
+ .eq('id', eventoId);
+ if (upErr) throw upErr;
+ }
+
+ // 2) Cria financial_record per_session (RPC ignora cancelled na idempotência)
+ const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
+ p_tenant_id: tenantId,
+ p_owner_id: ownerId,
+ p_patient_id: ev.patient_id || ev.paciente_id,
+ p_agenda_evento_id: eventoId,
+ p_amount: perSession,
+ p_due_date: dueDate
+ });
+ if (cobErr) throw cobErr;
+
+ // 3) Incrementa sessions_used + completa contract se necessário
+ const newUsed = (contract.sessionsUsed || 0) + 1;
+ const patchContract = { sessions_used: newUsed };
+ if (newUsed >= contract.totalSessions) {
+ patchContract.status = 'completed';
+ }
+ const { error: ctErr } = await supabase
+ .from('billing_contracts')
+ .update(patchContract)
+ .eq('id', contract.id);
+ if (ctErr) throw ctErr;
+
+ toast.add({
+ severity: 'success',
+ summary: 'Sessão usada do pacote',
+ detail: `Cobrança de R$ ${perSession.toFixed(2).replace('.', ',')} gerada. Saldo: ${newUsed}/${contract.totalSessions}.`,
+ life: 4000
+ });
+ M.refetch();
+ refetchEventosHoje();
+ // Fecha popover + dialogs eventuais (chamada pode vir do popover OU
+ // de qualquer um dos AgendaEventDialog empilhados)
+ fecharEvento();
+ if (M.dialogOpen) M.dialogOpen.value = false;
+ if (M.occDialogOpen) M.occDialogOpen.value = false;
+ } catch (e) {
+ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao usar sessão.', life: 5000 });
+ } finally {
+ eventoBusy.value = false;
+ }
+}
+
+// "Revogar" sessão usada do pacote saldo — desfaz onUsarSessao:
+// 1. Cancela financial_record (status='cancelled')
+// 2. Decrementa billing_contracts.sessions_used (se > 0)
+// 3. Se contract estava completed, volta pra active
+// 4. Status do evento volta pra 'agendado'
+// Constraint: só funciona se record ainda pending. Pago = bloqueado (precisa
+// estorno formal pelo Financeiro).
+async function onRevogarSessao(payload = null) {
+ // Aceita payload do dialog ({ eventRow, contract }) OU usa fallback
+ // do popover (eventoSelecionado + ev.contract).
+ const ev = payload?.eventRow || eventoSelecionado.value;
+ if (!ev || eventoBusy.value) return;
+ const contract = payload?.contract || ev?.contract;
+ if (!contract?.id) {
+ toast.add({ severity: 'warn', summary: 'Sem contrato', detail: 'Esta sessão não pertence a um pacote ativo.', life: 3000 });
+ return;
+ }
+ if (!ev.id || ev.is_occurrence) {
+ toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Sessão ainda não foi usada.', life: 3000 });
+ return;
+ }
+
+ eventoBusy.value = true;
+ try {
+ // Acha record vinculado a essa sessão e ao contrato
+ const { data: recs } = await supabase
+ .from('financial_records')
+ .select('id, status')
+ .eq('agenda_evento_id', ev.id)
+ .neq('status', 'cancelled')
+ .order('created_at', { ascending: false });
+ const activeRec = (recs || []).find((r) => r.status !== 'cancelled');
+ if (activeRec && activeRec.status === 'paid') {
+ toast.add({
+ severity: 'warn',
+ summary: 'Cobrança já paga',
+ detail: 'Esta sessão já foi paga. Estorne primeiro pelo Financeiro antes de revogar.',
+ life: 5000
+ });
+ return;
+ }
+
+ // 1) Cancela record (se ainda pending)
+ if (activeRec) {
+ const { error: cancelErr } = await supabase
+ .from('financial_records')
+ .update({ status: 'cancelled', updated_at: new Date().toISOString() })
+ .eq('id', activeRec.id);
+ if (cancelErr) throw cancelErr;
+ }
+
+ // 2) Decrementa sessions_used + reativa contract se estava completed
+ const newUsed = Math.max(0, (contract.sessionsUsed || 0) - 1);
+ const patchContract = { sessions_used: newUsed };
+ // Se contrato estava completed (sessionsUsed == total) e agora < total, reativa
+ if ((contract.sessionsUsed || 0) >= contract.totalSessions) {
+ patchContract.status = 'active';
+ }
+ const { error: ctErr } = await supabase
+ .from('billing_contracts')
+ .update(patchContract)
+ .eq('id', contract.id);
+ if (ctErr) throw ctErr;
+
+ // 3) Status do evento volta pra agendado.
+ // Backfill determined_commitment_id se estiver NULL (legado de uses
+ // antigos antes do fix). Garante que próxima abertura do dialog
+ // não exiba campo "Título" indevidamente.
+ const raw = ev._raw || {};
+ const patch = { status: 'agendado' };
+ if (!raw.determined_commitment_id && ev.recurrence_id) {
+ const { data: rule } = await supabase
+ .from('recurrence_rules')
+ .select('determined_commitment_id')
+ .eq('id', ev.recurrence_id)
+ .maybeSingle();
+ if (rule?.determined_commitment_id) {
+ patch.determined_commitment_id = rule.determined_commitment_id;
+ }
+ }
+ const { error: upErr } = await supabase
+ .from('agenda_eventos')
+ .update(patch)
+ .eq('id', ev.id);
+ if (upErr) throw upErr;
+
+ toast.add({
+ severity: 'success',
+ summary: 'Sessão revogada',
+ detail: `Cobrança cancelada. Saldo: ${newUsed}/${contract.totalSessions}.`,
+ life: 4000
+ });
+ M.refetch();
+ refetchEventosHoje();
+ fecharEvento();
+ if (M.dialogOpen) M.dialogOpen.value = false;
+ if (M.occDialogOpen) M.occDialogOpen.value = false;
+ } catch (e) {
+ toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao revogar sessão.', life: 5000 });
+ } finally {
+ eventoBusy.value = false;
+ }
+}
+
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -2249,6 +2489,8 @@ function onKeydown(e) {
@delete-sessao="onDeleteEvento"
@delete-series="onDeleteSeries"
@gerar-cobranca="onGerarCobrancaQuick"
+ @usar-sessao="onUsarSessao"
+ @revogar-sessao="onRevogarSessao"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente"
@@ -2688,6 +2930,8 @@ function onKeydown(e) {
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
@editSeriesOccurrence="M.onEditSeriesOccurrence"
+ @usar-sessao="onUsarSessao"
+ @revogar-sessao="onRevogarSessao"
/>