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
@@ -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.