agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix

DB
- migration 20260519000001: create_financial_record_for_session passa a
  ignorar status='cancelled' na idempotência (era bug — cancelar e tentar
  regerar travava silencioso retornando o cancelado)

Cenário 5 (convênio) — fixes pra save + visualização
- Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id
  e usa insurance_value. payment_method forçado 'convenio' (era 'asaas')
- Popover: ev.price era null em convênio → normalize expõe insurance_value
  e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente
- /financeiro: branch novo pra payment_method='convenio' → pill violeta
  com pi-id-card (antes ficava sem indicador, igual particular)

Cenário 6 (recorrente sem pacote, Maria Magali) — materialização
- chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem
  badge $). Agora materializa a 1ª no fluxo de criação recorrente
- Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository
  dropa esse campo. Corrigido pra 'patient_id' (English DB column)

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant
  ='none' + sessão materializada)
- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick
  (chama gerarCobrancaManual, fecha popover pra impedir double-click)
- Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel
  agora filtram status='cancelled' (resolve badge $ residual + botão sumido)

Header do popover: info de pacote/série
- "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo
  (computed seriesLabel lê do _raw da rule)

Título do dialog "Sessão do Pacote · Sessão"
- Quando commitment name é "Sessão" (default), drop pra evitar duplicação
- Outros nomes (Avaliação, etc) permanecem com forma completa

Excluir série inteira (popover)
- Novo botão "Excluir série" no popover quando evento pertence a recorrência
- Hard delete: financial_records pendentes → agenda_eventos materializados
  → recurrence_rules (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem status='paid' (estornar primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception type=
  'cancel_session' (era visível com status cancelado; doc dizia "some
  da agenda" mas código mantinha. Honra a promessa do diálogo)
- patient_missed / therapist_canceled / holiday_block permanecem visíveis
  como histórico

UX outros
- "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava
  — empty state mandava clicar em botão inexistente)
- InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA
  do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada
  estava selecionado antes
- Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia
  opcional"; gratuito = "sem cobrança". Particular sem hint
- recurrence_exceptions cancel agora usa upsert com onConflict
  (idempotente, não quebra com unique violation em re-cancel)
- goToConveniosConfig removida (dead code após quick-create inline)

CSS
- .aed-row-50 perdeu margin-bottom (user request)
- .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 16:23:42 -03:00
parent e95ed9b585
commit c23d0a574f
10 changed files with 589 additions and 58 deletions
@@ -152,6 +152,10 @@ function normalizeForMelissa(r, paymentStateMap = null) {
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
paymentState,
price: r.price != null ? Number(r.price) : null,
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
// price). Popover e Resumo usam fallback `price ?? insurance_value`
// pra mostrar o valor da cobrança independente do tipo.
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
};
}
@@ -459,10 +463,14 @@ export function useMelissaAgenda() {
// resolver (default 'none').
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
if (realIds.length) {
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
const { data: recs } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at')
.in('agenda_evento_id', realIds);
.select('agenda_evento_id, paid_at, status')
.in('agenda_evento_id', realIds)
.neq('status', 'cancelled');
const map = {};
for (const id of realIds) map[id] = 'none';
for (const rec of recs || []) {
@@ -1551,6 +1559,53 @@ function _buildOnDialogSave(deps) {
}
}
// chargeMode='none' (C6 doc): materializa a 1ª ocorrência
// sem criar financial_record. As demais ficam virtuais.
// Honra: "1ª materializada com badge $ a cobrar; outras 3
// virtuais expandidas em runtime, limpas até interação".
// package/per_session ja materializam dentro dos proprios
// helpers; package 'saldo' intencionalmente NÃO materializa
// (doc C8 — modelo Cliniko, todas virtuais ate interacao).
// IMPORTANTE: agendaRepository.createAgendaEvento dropa
// 'paciente_id' (campo legado). USAR 'patient_id' (English),
// que é o nome real da coluna em agenda_eventos.
const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
try {
const durMin = createdRule.duration_min || 50;
const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = createdRule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
await create({
owner_id: createdRule.owner_id,
tenant_id: clinicId,
terapeuta_id: createdRule.therapist_id ?? null,
recurrence_id: createdRule.id,
recurrence_date: firstISO,
tipo: normalized.tipo || 'sessao',
status: normalized.status || 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: _patientIdForFirst,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: normalized.modalidade ?? 'presencial',
observacoes: normalized.observacoes ?? null,
extra_fields: normalized.extra_fields ?? null,
price: normalized.price ?? null,
insurance_plan_id: normalized.insurance_plan_id ?? null,
insurance_guide_number: normalized.insurance_guide_number ?? null,
insurance_value: normalized.insurance_value ?? null,
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
visibility_scope: normalized.visibility_scope || 'public'
});
} catch (e) {
console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
}
}
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
if (chargeInfo?.toast) toast.add(chargeInfo.toast);
@@ -1790,7 +1845,15 @@ function _buildOnDialogSave(deps) {
// status='paid' apenas quando markPaidNow && method !== 'link'.
if (arg?.chargeMode === 'session' && createdEventId) {
try {
const amount = normalized.price ?? 0;
// Convênio: valor vem em insurance_value (não price);
// payment_method fixo em 'convenio' (não asaas/pix/etc) —
// o plano paga a clínica no fechamento mensal, nunca via
// gateway ou no caixa. markPaidNow ignorado pra convênio
// (record sempre nasce pending até a baixa via Financeiro).
const isConvenio = !!normalized.insurance_plan_id;
const amount = isConvenio
? (normalized.insurance_value ?? 0)
: (normalized.price ?? 0);
const dueDate = normalized.inicio_em
? new Date(normalized.inicio_em).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
@@ -1805,12 +1868,16 @@ function _buildOnDialogSave(deps) {
if (cobErr) throw cobErr;
// Pós-RPC: ajusta payment_method (sempre) e status (só se
// markPaidNow=true e método direto).
// method='link' → payment_method='asaas', status=pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const method = arg?.paymentMethod || 'link';
const paidNow = arg?.markPaidNow === true && method !== 'link';
// markPaidNow=true e método direto). Convênio força method
// = 'convenio' e ignora markPaidNow.
// convenio → payment_method='convenio', status=pending
// method='link' → payment_method='asaas', status=pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const method = isConvenio
? 'convenio'
: (arg?.paymentMethod || 'link');
const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
try {
const { data: recRow } = await supabase
.from('financial_records')
@@ -1839,14 +1906,15 @@ function _buildOnDialogSave(deps) {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)'
cartao_maquininha: 'cartão (maquininha)',
convenio: 'convênio'
}[method] || null;
toast.add({
severity: 'success',
summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
detail: paidNow
? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}.`,
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
life: 3500
});
} catch (e) {