agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)
Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
Handler aplica payment_method sempre; status='paid'+paid_at apenas
quando markPaidNow=true && method != 'link'. Asaas (link) sempre
liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
e (opcional) status='paid' quando user marca "ja recebi".
Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
pi-map-marker via novo sessionPaymentRecord (sem guard de
occurrenceMode, contrario ao occFinancialRecord que continua so pra
Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
sem cobranca c/ valor, sem cobranca s/ valor.
UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
selecionado, com copy variavel (0 procedimentos: chamada urgente;
1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.
Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
— sessoes avulsas eram salvas como presencial independente da
escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
_buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
escopo de _buildHandlers).
Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
status pra realizada/faltou/cancelado, com opcoes de markPaid ou
gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
cinza (background events) do MelissaAgenda.
Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
de teste manual. C1-C4 ja validados. Cada teste validado vira parte
da doc final pra area de ajuda (pos-Fase 9).
Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
arquiteturais sobre billing).
- HANDOFF.md atualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,17 +85,26 @@ const TYPE_FILTER_OPTIONS = [
|
||||
];
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ label: 'Pix', value: 'pix' },
|
||||
{ label: 'Depósito', value: 'deposito' },
|
||||
{ label: 'Dinheiro', value: 'dinheiro' },
|
||||
{ label: 'Cartão', value: 'cartao' },
|
||||
{ label: 'Convênio', value: 'convenio' }
|
||||
{ label: 'Pix', value: 'pix' },
|
||||
{ label: 'Depósito', value: 'deposito' },
|
||||
{ label: 'Dinheiro', value: 'dinheiro' },
|
||||
{ label: 'Cartão', value: 'cartao' },
|
||||
{ label: 'Cartão (maquininha)', value: 'cartao_maquininha' },
|
||||
{ label: 'Convênio', value: 'convenio' },
|
||||
{ label: 'Asaas', value: 'asaas' }
|
||||
];
|
||||
|
||||
function paymentLabel(method) {
|
||||
return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—';
|
||||
}
|
||||
|
||||
// Abre link de cobrança externa (Asaas/etc) em nova aba.
|
||||
// noopener/noreferrer pra segurança (gateway não vira janela parent). 2026-05-14.
|
||||
function openPaymentLink(url) {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
// ── Filtros reativos ──────────────────────────────────
|
||||
const filterStatus = ref(null);
|
||||
const filterType = ref(null);
|
||||
@@ -123,6 +132,38 @@ function clearAllFilters() {
|
||||
filterDateRange.value = null;
|
||||
}
|
||||
|
||||
// Aninhamento visual (2026-05-14): records com mesmo agenda_evento_id viram
|
||||
// "pai + filho(s)" — o mais antigo (created_at) é o pai (sessão); demais
|
||||
// (multa, taxa de cancelamento, etc) aparecem indentados embaixo. Pai
|
||||
// sempre antes dos filhos na lista. Records sem agenda_evento_id (avulso
|
||||
// manual) ficam como itens soltos. Não reordena entre grupos — só dentro
|
||||
// de cada grupo, preservando ordem de chegada do servidor.
|
||||
const recordsGrouped = computed(() => {
|
||||
const list = records.value || [];
|
||||
if (list.length === 0) return list;
|
||||
const groupOrder = [];
|
||||
const groups = new Map();
|
||||
for (const r of list) {
|
||||
const key = r.agenda_evento_id || `solo-${r.id}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
groupOrder.push(key);
|
||||
}
|
||||
groups.get(key).push(r);
|
||||
}
|
||||
const out = [];
|
||||
for (const key of groupOrder) {
|
||||
const group = groups
|
||||
.get(key)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
group.forEach((r, idx) => {
|
||||
out.push({ ...r, _isChild: idx > 0 && group.length > 1, _hasChildren: idx === 0 && group.length > 1 });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// ── Paginação server-side ─────────────────────────────
|
||||
const pageFirst = ref(0);
|
||||
const pageRows = ref(20);
|
||||
@@ -531,7 +572,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="records"
|
||||
:value="recordsGrouped"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
lazy
|
||||
@@ -545,13 +586,20 @@ onBeforeUnmount(() => {
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
tableStyle="min-width: 880px"
|
||||
:rowClass="(r) => (r.status === 'overdue' ? 'mfl-row-overdue' : '')"
|
||||
:rowClass="(r) => [r.status === 'overdue' ? 'mfl-row-overdue' : '', r._isChild ? 'mfl-row-child' : '', r._hasChildren ? 'mfl-row-parent' : ''].filter(Boolean).join(' ')"
|
||||
class="mfl-table"
|
||||
@page="onPageChange"
|
||||
>
|
||||
<Column header="Paciente" style="min-width: 13rem">
|
||||
<template #body="{ data }">
|
||||
<div class="mfl-row__patient">
|
||||
<!-- Em records "filhos" (multa, taxa) do mesmo agenda_evento_id,
|
||||
esconde avatar+nome e mostra "↳ {descrição}" indentado.
|
||||
Mesmo paciente do pai logo acima → reduz ruído visual. -->
|
||||
<div v-if="data._isChild" class="mfl-row__child">
|
||||
<i class="pi pi-arrow-right-and-arrow-left-up-down mfl-row__child-icon" />
|
||||
<span class="mfl-row__child-label">{{ data.description || 'Cobrança extra' }}</span>
|
||||
</div>
|
||||
<div v-else class="mfl-row__patient">
|
||||
<span
|
||||
class="mfl-row__avatar"
|
||||
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
|
||||
@@ -620,7 +668,26 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Column header="Ações" style="width: 11rem; min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-1">
|
||||
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-2">
|
||||
<!-- Info do método (Asaas/etc): ícone + texto em azul info
|
||||
na linha de cima; "Ver boleto" como texto-link na linha
|
||||
de baixo (disabled enquanto integração Asaas não preenche
|
||||
payment_link, tooltip muda dinâmico). 2026-05-14. -->
|
||||
<div v-if="data.payment_method === 'asaas'" class="mfl-row__pending-asaas">
|
||||
<div class="mfl-row__pending-method">
|
||||
<i class="pi pi-link" />
|
||||
{{ paymentLabel(data.payment_method) }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mfl-row__pending-link"
|
||||
:disabled="!data.payment_link"
|
||||
v-tooltip.top="data.payment_link ? 'Abrir link de pagamento' : 'Aguardando integração Asaas'"
|
||||
@click="openPaymentLink(data.payment_link)"
|
||||
>
|
||||
Ver boleto
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
label="Receber"
|
||||
icon="pi pi-check"
|
||||
@@ -1336,6 +1403,21 @@ onBeforeUnmount(() => {
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
/* Aninhamento visual (2026-05-14): pai ganha border-bottom mais discreto,
|
||||
filho herda fundo sutil + sem border-top → parece "continuação" do pai. */
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-parent > td) {
|
||||
border-bottom-style: dashed !important;
|
||||
border-bottom-color: var(--m-border, rgba(255, 255, 255, 0.08)) !important;
|
||||
}
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child) {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child > td) {
|
||||
border-top: none !important;
|
||||
}
|
||||
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child:hover) {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.mfl-table :deep(.p-datatable-loading-overlay) {
|
||||
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
|
||||
@@ -1388,6 +1470,28 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Bloco "filho" (multa/taxa do mesmo agenda_evento): indent + ícone setinha. */
|
||||
.mfl-row__child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mfl-row__child-icon {
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.65rem;
|
||||
transform: scaleY(-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mfl-row__child-label {
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--m-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mfl-row__avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
@@ -1457,6 +1561,40 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
.mfl-row__pending-asaas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mfl-row__pending-method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.74rem;
|
||||
color: rgb(37, 99, 235); /* azul info — cobrança aguardando, não paga */
|
||||
font-weight: 500;
|
||||
}
|
||||
/* "Ver boleto" como texto-link (sem botão visual). Habilitado quando
|
||||
payment_link existe — vira underline + cursor pointer. Disabled hoje
|
||||
enquanto integração Asaas não preenche — tooltip explica. 2026-05-14. */
|
||||
.mfl-row__pending-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
color: rgb(37, 99, 235);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mfl-row__pending-link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mfl-row__pending-link:disabled {
|
||||
color: var(--m-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mfl-row__none {
|
||||
color: var(--m-text-faint);
|
||||
font-style: italic;
|
||||
|
||||
Reference in New Issue
Block a user