agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX
Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
Visual: 12 virtuais limpas no calendário.
UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
(id, style, totalSessions, sessionsUsed, packagePrice) por
recurrence_id. Query recurrence_rules.patient_id como fonte
autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
modelo, gateado por occFinancialLoading (spinner durante carga
pra evitar piscar entre Usar/Revogar)
Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
determined_commitment_id da regra) → status=realizado +
billing_contract_id → create_financial_record_for_session →
sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
se estava completed + status=agendado. Bloqueia se record paid
(precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
"Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
"Revogar uso" no info card do dialog
Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
Corrigido em todas as ocorrências do handler
Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
path); update path backfilla via query da rule se NULL; Revogar
também backfilla pra consistência
Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
sessions_used. Fluxo correto: "Usar"
Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
faltou, stone-500 cancelado, violet-600 remarcado)
Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
occFinancialRecord async; durante ~500ms de load, botão errado
podia piscar. Fix: spinner "Verificando estado…" enquanto
occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)
3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
<!-- 2º AgendaEventDialog — empilhado por cima do principal pra editar
|
||||
@@ -2716,6 +2960,8 @@ function onKeydown(e) {
|
||||
@save="M.onDialogSave"
|
||||
@delete="M.onDialogDelete"
|
||||
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||||
@usar-sessao="onUsarSessao"
|
||||
@revogar-sessao="onRevogarSessao"
|
||||
/>
|
||||
|
||||
<!-- BloqueioDialog — bloqueio de horário/período/dia/feriados.
|
||||
|
||||
Reference in New Issue
Block a user