agenda: C9 OK + rowGroup por paciente em /financeiro + bubble cobranca-atualizada

Cenário 9 (Per-session — Michael Balint 12 × R$ 150)
- Testado e passou. 1 rule + 12 agenda_eventos materializadas + 12
  financial_records pending. Sem billing_contract. Badge $ em todas as
  12 sessões. Conforme esperado.

/melissa/financeiro-lancamentos: agrupado por paciente
- DataTable com rowGroupMode='subheader' + groupRowsBy='patient_id'
- Header de grupo com avatar + nome + badge "N lançamento(s)"
- expandableRowGroups + v-model:expandedRowGroups; watcher popula
  todos os grupos da página atual como expandidos (sempre que
  recordsGrouped muda — refletindo paginação/filtros)
- Sort outer por nome do paciente, preserva inner order
  (pai → filhos de multas/taxas via mesmo agenda_evento_id)

Bubble-up @cobranca-atualizada → M.refetch
- Antes: ao marcar como pago no dialog, o card no FC ficava stale
  até trocar de view. AgendaEventoFinanceiroPanel emitia
  cobranca-atualizada mas só o loadOccFinancialRecord do dialog
  escutava; o _paymentStateMap da agenda nao re-rodava.
- Fix: AgendaEventDialog ganhou _onCobrancaAtualizada que dispara
  loadOccFinancialRecord() E emit('cobranca-atualizada') pra cima.
  MelissaLayout escuta nos 2 dialogs e chama M.refetch() +
  refetchEventosHoje(). Card passa pra borda verde na hora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 23:55:06 -03:00
parent fad1f4ebd4
commit ec0a24f5c8
5 changed files with 177 additions and 35 deletions
+56 -31
View File
@@ -1,46 +1,70 @@
# HANDOFF — 2026-05-19 madrugada (C1-C8 + UI saldo + Usar/Revogar, próximo C9)
# HANDOFF — 2026-05-20 madrugada (C1-C9 ✅, próximo C10)
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
> testes manuais dos 13 cenários do doc viva
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C8 ✅**.
> Próximo: **Cenário 9** (Michael Balint · per_session · 12 × R$ 150 —
> materializa todas 12 sessões + cria 12 records pendentes upfront).
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C9 ✅**.
> Próximo: **Cenário 10** (status change avulsa — Joyce/Ana/Sándor:
> marcar como realizado/faltou/cancelado e ver consequências no
> financeiro via STATUS_TO_EXCEPTION + financial_exceptions).
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 madrugada. UI de
> pacote saldo completa (info card violeta + botão Usar/Revogar atômico
> que materializa+realiza+cobra ou desfaz). Backfill de
> determined_commitment_id em revogar+usar (evita campo "Título" indevido
> em sessões legadas).
> **🟢 WORKING TREE LIMPO** após commit/push do checkpoint pós-C8/C9.
> Per-session funcionando (12 events + 12 records gerados em batch).
> Financeiro com rowGroup por paciente (expand/collapse). Bubble-up
> @cobranca-atualizada → M.refetch faz o card da agenda atualizar
> badge/borda imediatamente após pagar.
---
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 9 (Per-session)
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 10 (Status change AVULSA)
| Campo | Valor |
|---|---|
| Paciente | **Michael Balint** |
| Frequência | **Semanal · 12 ocorrências** |
| Serviço | Sessão R$ 150 |
| Cobrança ao salvar | **1 por sessão** (chargeMode=per_session) |
Doc HTML diz: testar status change numa sessão avulsa com cobrança pendente,
mudando entre realizado / faltou / cancelado. As consequências financeiras
seguem `financial_exceptions` (regras configuradas pelo terapeuta sobre o
que acontece com a cobrança nesses casos).
**Esperado:**
- 1 row em `recurrence_rules`
- **12 rows em `agenda_eventos`** (TODAS materializadas — diferença chave vs C6-C8)
- 0 rows em `billing_contracts` (per_session NÃO usa contrato)
- **12 rows em `financial_records`** (1 por sessão, todos pending, amount=150 cada)
- Agenda: 12 sessões com **badge $ amber** em todas (cada uma tem seu record próprio)
- Popover de cada uma: linha amber "A receber R$ 150,00 (cobrança pendente)"
Possíveis pacientes pra teste: usar Joyce, Sándor ou outro com cobrança
avulsa pendente já criada.
Diferença chave vs cenários anteriores:
- C6 (none): 1 row materializada + 0 records
- C7 (upfront): 1 row + 1 record (R$ totalPacote)
- C8 (saldo): 0 rows + 0 records (modelo Cliniko, geram on-the-fly via Usar)
- **C9 (per_session): N rows + N records pré-criados**
**Esperado** (depende das `financial_exceptions` configuradas no tenant):
- Realizada: status muda; cobrança permanece (caminho default)
- Faltou: pode ter regra → cobrança 100% (paciente paga falta) ou cancela
- Cancelado: pode ter regra → cancelar cobrança ou cobrar parcial
Após C9: C10-C13 (status change + edit cobrada).
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
Conferir:
- `STATUS_TO_EXCEPTION` mapping em `useAgendaFinanceiro.js`
- `getFinancialExceptionRule(tenantId, exceptionType)` retorna a regra
- `handleStatusChange` orquestra: agenda update + financial adjust
Após C10: C11 (status change pacote saldo — usar a infra do Usar/Revogar)
→ C12 (antecipar pagamento) → C13 (edit cobrada).
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e
**Clínica** (`AgendaClinicaPage.vue`).
---
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
### Cenário 9 ✅ (Per-session — Michael Balint 12 × R$ 150)
Testado e passou. Criou-se 1 rule + 12 agenda_eventos materializadas + 12 financial_records pending. Sem billing_contract. Cada sessão com badge $ amber individual. **Sem nenhuma `linha de pacote`** no popover (não tem contract → não aparece). Conforme esperado.
### `/melissa/financeiro-lancamentos` agrupado por paciente
- DataTable com `rowGroupMode='subheader'` + `groupRowsBy='patient_id'`
- Default: todos os grupos da página expandidos (watcher popula `expandedGroups` com unique patient_ids quando `recordsGrouped` muda)
- Header de grupo: avatar pequeno + nome + badge "N lançamento(s)"
- Click no chevron contrai/expande (auto via PrimeVue `expandableRowGroups`)
- Sort estável: ordena outer por nome do paciente, preserva inner order (pai → filhos de multas/taxas)
### Bubble-up `@cobranca-atualizada`
Antes: `AgendaEventoFinanceiroPanel.@cobranca-atualizada` disparava só `loadOccFinancialRecord` (interno do dialog). O `_paymentStateMap` da agenda ficava stale → card no FC só atualizava ao trocar de view.
Agora: `AgendaEventDialog._onCobrancaAtualizada` faz duas coisas:
1. `loadOccFinancialRecord()` — refresca estado interno do dialog
2. `emit('cobranca-atualizada')` — bubble pra MelissaLayout
MelissaLayout escuta nos 2 dialogs (principal + occurrenceMode) e chama `onCobrancaAtualizada` que dispara `M.refetch() + refetchEventosHoje()`. Resultado: card na agenda passa pra borda verde imediatamente após marcar pago.
---
@@ -282,7 +306,8 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
| **9** | **1 por sessão (Michael Balint 12 × R$ 150)** | 🔴 **PRÓXIMO** |
| 9 | 1 por sessão (Michael Balint 12 × R$ 150) | ✅ |
| **10** | **Status change avulsa (realizado/faltou/cancelado)** | 🔴 **PRÓXIMO** |
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
| 11 | Status change pacote saldo | ⏳ |
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
+32
View File
@@ -14,6 +14,38 @@ Chronological, append-only record of everything that's happened in this wiki.
---
## [2026-05-20 06:00] session | C9 OK + rowGroup por paciente + bubble cobranca-atualizada
Touched: none (codigo + HANDOFF)
Detalhes:
CENARIO 9 (per_session):
- Michael Balint 12 sessoes R$ 150 modo "1 por sessao"
- DB conforme esperado: 1 rule, 12 events materializadas, 0 contracts,
12 records pending (R$ 150 cada)
- Visual: 12 badges $ amber, todas pending. Sem linha de pacote no
popover (per_session nao usa contract).
ROWGROUP NO /melissa/financeiro-lancamentos:
- DataTable com rowGroupMode='subheader' + groupRowsBy='patient_id'
- Default todos expandidos (watcher recalcula a cada recordsGrouped)
- Header de grupo: avatar + nome + badge "N lançamento(s)"
- Sort outer por nome de paciente, preserva ordem interna pai→filhos
- Click no chevron contrai/expande
BUBBLE @cobranca-atualizada:
- Bug: card na agenda nao atualizava badge/borda apos marcar como
pago no dialog (so dava refresh ao trocar de view)
- Root cause: AgendaEventoFinanceiroPanel.@cobranca-atualizada
disparava so loadOccFinancialRecord (interno do dialog). O
_paymentStateMap da agenda ficava stale.
- Fix: AgendaEventDialog tem agora _onCobrancaAtualizada que faz
loadOccFinancialRecord + emit('cobranca-atualizada'). MelissaLayout
escuta nos 2 dialogs e chama M.refetch() + refetchEventosHoje().
Card no FC passa pra borda verde imediatamente.
PROXIMO: Cenario 10 (status change avulsa — realizado/faltou/cancelado
+ STATUS_TO_EXCEPTION em financial_exceptions).
## [2026-05-20 03:00] session | C8 OK + Usar/Revogar saldo + UI pacote + ajustes UX
Touched: none (sem nova wiki page; mudancas em codigo + HANDOFF)
Detalhes: noite longa cobrindo C8 (pacote saldo) e principalmente
@@ -147,7 +147,16 @@ const props = defineProps({
blockOverlapWarning: { type: Object, default: null }
});
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated', 'usar-sessao', 'revogar-sessao']);
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated', 'usar-sessao', 'revogar-sessao', 'cobranca-atualizada']);
// Helper: chamado pelo AgendaEventoFinanceiroPanel quando a cobrança
// muda (gerada, paga, cancelada). Refresha estado interno do dialog
// E bubble pra MelissaLayout disparar refetch da agenda (sem isso, o
// card do FC fica com paymentState stale até trocar de view).
function _onCobrancaAtualizada() {
loadOccFinancialRecord();
emit('cobranca-atualizada');
}
const confirm = useConfirm();
const toast = useToast();
const router = useRouter();
@@ -1823,7 +1832,7 @@ onBeforeUnmount(() => {
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized" :evento="eventRow" class="m-3" @cobranca-atualizada="_onCobrancaAtualizada" />
</template>
<!-- LOADING -->
@@ -2338,7 +2347,7 @@ onBeforeUnmount(() => {
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="_onCobrancaAtualizada" />
</template>
<Transition v-else-if="!occFinancialLoading" name="aed-pay-expand">
<div class="field-card__body aed-pay-body">
@@ -2407,7 +2416,7 @@ onBeforeUnmount(() => {
v-if="!occFinancialRecord && !occFinancialLoading && isEdit && eventRow?.id"
:evento="eventRow"
class="m-3"
@cobranca-atualizada="loadOccFinancialRecord"
@cobranca-atualizada="_onCobrancaAtualizada"
/>
<!-- Botão "Ver lançamentos" (2026-05-14): abre dialog
@@ -161,9 +161,37 @@ const recordsGrouped = computed(() => {
out.push({ ...r, _isChild: idx > 0 && group.length > 1, _hasChildren: idx === 0 && group.length > 1 });
});
}
// Ordena por paciente pra rowGroupMode='subheader' do PrimeVue agrupar
// corretamente. Sort estável preserva ordem interna (parent → children
// do mesmo agenda_evento_id; séries em ordem cronológica).
out.sort((a, b) => {
const aName = a.patients?.nome_completo || '~'; // sem paciente vai pro final
const bName = b.patients?.nome_completo || '~';
return aName.localeCompare(bName, 'pt-BR');
});
return out;
});
// Grupos expandidos (default: todos os pacientes da página atual).
// Watcher recalcula sempre que recordsGrouped muda — quando user troca
// pagina/filtro, todos os grupos da nova lista entram expandidos.
const expandedGroups = ref([]);
watch(
recordsGrouped,
(list) => {
const ids = [...new Set((list || []).map((r) => r.patient_id).filter(Boolean))];
expandedGroups.value = ids;
},
{ immediate: true }
);
// Conta lançamentos por paciente na lista atual — exibido no header
// de grupo. Inclui só "pais" (não conta filhos de cobranças extras pra
// não inflar o número).
function _countForPatient(pid) {
if (!pid) return 0;
return (recordsGrouped.value || []).filter((r) => r.patient_id === pid && !r._isChild).length;
}
// ── Paginação server-side ─────────────────────────────
const pageFirst = ref(0);
const pageRows = ref(20);
@@ -588,8 +616,24 @@ onBeforeUnmount(() => {
tableStyle="min-width: 880px"
:rowClass="(r) => [r.status === 'overdue' ? 'mfl-row-overdue' : '', r._isChild ? 'mfl-row-child' : '', r._hasChildren ? 'mfl-row-parent' : ''].filter(Boolean).join(' ')"
class="mfl-table"
rowGroupMode="subheader"
groupRowsBy="patient_id"
:expandableRowGroups="true"
v-model:expandedRowGroups="expandedGroups"
@page="onPageChange"
>
<template #groupheader="{ data }">
<div class="mfl-group-header">
<span
class="mfl-row__avatar"
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
>
{{ data.patients?.nome_completo?.[0]?.toUpperCase() ?? '?' }}
</span>
<span class="mfl-group-header__name">{{ data.patients?.nome_completo ?? 'Sem paciente' }}</span>
<span class="mfl-group-header__count">{{ _countForPatient(data.patient_id) }} lançamento(s)</span>
</div>
</template>
<Column header="Paciente" style="min-width: 13rem">
<template #body="{ data }">
<!-- Em records "filhos" (multa, taxa) do mesmo agenda_evento_id,
@@ -1471,6 +1515,27 @@ onBeforeUnmount(() => {
}
/* Row content */
/* Group header — paciente como subheader da DataTable, com avatar
pequeno + nome + contagem. Click no chevron (auto via PrimeVue
expandableRowGroups) expande/contrai o bloco do paciente. */
.mfl-group-header {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.mfl-group-header__name {
color: var(--text-color);
font-size: 0.92rem;
}
.mfl-group-header__count {
font-size: 0.72rem;
color: var(--text-color-secondary);
font-weight: 500;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--surface-border), transparent 40%);
}
.mfl-row__patient {
display: flex;
align-items: center;
+11
View File
@@ -1485,6 +1485,15 @@ async function onRevogarSessao(payload = null) {
}
}
// Bubble up do AgendaEventoFinanceiroPanel via AgendaEventDialog.
// Disparado quando cobrança é gerada / paga / cancelada dentro do
// dialog. Re-roda o bulk-load do useMelissaAgenda pra que o FC
// atualize badge $ / borda verde sem precisar trocar de view.
function onCobrancaAtualizada() {
M.refetch();
refetchEventosHoje();
}
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -2932,6 +2941,7 @@ function onKeydown(e) {
@editSeriesOccurrence="M.onEditSeriesOccurrence"
@usar-sessao="onUsarSessao"
@revogar-sessao="onRevogarSessao"
@cobranca-atualizada="onCobrancaAtualizada"
/>
<!-- 2º AgendaEventDialog empilhado por cima do principal pra editar
@@ -2962,6 +2972,7 @@ function onKeydown(e) {
@updateSeriesEvent="M.onUpdateSeriesEvent"
@usar-sessao="onUsarSessao"
@revogar-sessao="onRevogarSessao"
@cobranca-atualizada="onCobrancaAtualizada"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados.