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
@@ -924,7 +924,14 @@ const headerMainLabel = computed(() => {
const name = selectedCommitment.value?.name || '';
if (!name) return headerTitle.value;
if (isEdit.value) {
if (hasSerie.value) return `Sessão do Pacote · ${name}`;
if (hasSerie.value) {
// Evita "Sessão do Pacote · Sessão" (nome duplicado) — quando
// o commitment chama 'Sessão' (default), mostra só "Sessão do
// Pacote". Outros nomes (ex: Avaliação) entram normais.
const lower = name.toLowerCase();
if (lower === 'sessão' || lower === 'sessao') return 'Sessão do Pacote';
return `Sessão do Pacote · ${name}`;
}
return `Editar ${name}`;
}
if (isPacote.value) {
@@ -2303,20 +2310,6 @@ onBeforeUnmount(() => {
</Message>
</div>
<!-- Hint contextual abaixo do card Sessão / Honorários
(fluxo principal Melissa, fora do occurrenceMode).
Esclarece dúvidas comuns por tipo de cobrança sem
poluir o form. Esconde quando cobrança paga/
pendente (lock-edit) a Message do panel cobre. -->
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-3">
<i class="pi pi-info-circle" />
<span><b> da guia é opcional</b> você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
</div>
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-3">
<i class="pi pi-gift" />
<span>Sessão <b>gratuita</b> nenhum lançamento será gerado no Financeiro.</span>
</div>
<!-- FREQUÊNCIA ( sessão, sem série) -->
<div v-if="isSessionEvent && !hasSerie" class="field-card mb-4">
<div class="field-card__header">
@@ -2431,6 +2424,21 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Hint contextual por tipo de cobrança fullwidth, acima
do card Extras. Estava antes dentro do aed-row-50 (grid
50/50) e ocupava uma cell, empurrando Frequência pra
baixo. Movido pra fora do row em 2026-05-19.
Esconde quando cobrança paga/pendente (lock-edit)
a Message do panel cobre. -->
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-4">
<i class="pi pi-info-circle" />
<span><b> da guia é opcional</b> você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
</div>
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-4">
<i class="pi pi-gift" />
<span>Sessão <b>gratuita</b> nenhum lançamento será gerado no Financeiro.</span>
</div>
<!-- EXTRAS (herdados do compromisso determinado) último card.
'notes' é caso especial: bindamos em form.observacoes pra
manter compat com a coluna nativa agenda_eventos.observacoes
@@ -4351,7 +4359,8 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
margin-bottom: 1rem;
/* margin-bottom removido em 2026-05-19 (user request) — o espaçamento
agora vem do mt do próximo .field-card.mb-4 (ver regra abaixo). */
}
@media (min-width: 768px) {
.aed-row-50 {
@@ -4363,6 +4372,16 @@ onBeforeUnmount(() => {
margin-bottom: 0 !important;
}
/* mt-4 em .field-card.mb-4 (user request 2026-05-19) — pra compensar
a remoção do mb do .aed-row-50 e dar respiro vertical entre os
cards do composer. Restrito aos wrappers do composer pra não
afetar field-cards de outros lugares. */
.composer-left .field-card.mb-4,
.composer-right .field-card.mb-4,
.composer-occurrence .field-card.mb-4 {
margin-top: 1rem;
}
/* ── side panel ─────────────────────────────────── */
.composer-right {
/* layout single-col agora — sticky removido (nao faz sentido) */
@@ -247,8 +247,16 @@ export function expandRules(rules, exceptions, rangeStart, rangeEnd) {
if (exception) handledExIds.add(exception.id);
// ── exceção: cancela esta ocorrência ──
if (exception?.type === 'cancel_session' || exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') {
// ainda inclui no calendário mas com status especial
// cancel_session = cancel preemptivo (terapeuta apagou antes de
// qualquer interação) → SOME da agenda (honra o "Ela some da
// agenda" do diálogo de exclusão).
if (exception?.type === 'cancel_session') {
continue;
}
// Demais cancelamentos são HISTÓRICO (paciente faltou, terapeuta
// cancelou após começar, feriado) → permanecem visíveis com
// status especial pra ficar no registro.
if (exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') {
occurrences.push(buildOccurrence(rule, date, iso, exception));
continue;
}