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:
Leonardo
2026-05-19 08:31:18 -03:00
parent 41c44272a3
commit e95ed9b585
41 changed files with 8715 additions and 852 deletions
+88 -5
View File
@@ -394,6 +394,18 @@ const fcEvents = computed(() => {
// abaixo. Mantem a cor do commitment pra nao perder contexto.
const pStatus = ev.paciente_status;
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
// Sessão paga → barra esquerda verde (override do border-left que o
// FC pinta com a cor do commitment). Espelha as mesmas condições do
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
// paymentState === 'paid'.
const isPaidSession =
String(ev.tipo || '').toLowerCase() === 'sessao' &&
(ev.patient_id || ev.paciente_id) &&
!ev.is_occurrence &&
ev.paymentState === 'paid';
const cls = [];
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
if (isPaidSession) cls.push('ma-evt--paid');
out.push({
id: ev.id,
title: ev.label,
@@ -402,7 +414,7 @@ const fcEvents = computed(() => {
backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color,
textColor: 'white',
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
classNames: cls.length ? cls : undefined,
extendedProps: ev
});
}
@@ -411,6 +423,11 @@ const fcEvents = computed(() => {
if (feriados.length) {
for (const f of feriados) out.push(f);
}
// Bloqueios (background events cinza) — concat direto sem filtro.
const blqs = bloqueioFcEvents.value;
if (blqs.length) {
for (const b of blqs) out.push(b);
}
return out;
});
@@ -568,7 +585,11 @@ const fcOptions = computed(() => ({
},
eventClick: (info) => {
const ev = info.event.extendedProps;
if (ev) emit('select-evento', ev);
if (!ev) return;
// Bloqueios e pausas semanais são background events não-clicáveis —
// o painel lateral é só pra sessões/compromissos reais.
if (ev.kind === 'bloqueio' || ev.kind === 'break') return;
emit('select-evento', ev);
},
// Drag → reagenda evento (mesmo dia, hora diferente OU outro dia)
eventDrop: (info) => {
@@ -621,6 +642,18 @@ const fcOptions = computed(() => ({
</div>
`;
}
// Badge "$ a receber" — sessão com paciente, ainda não paga.
// Cobre Cenário 2 (sem cobrança, sem record) e Cenário 3 (cobrança
// pendente). Esconde quando paid ou quando não é sessão com paciente.
// Ocorrências virtuais sempre 'none' até serem materializadas — pra
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
// pelo contrato, não por record-por-sessão).
let payBadgeHtml = '';
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
}
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
// antigo `__meta` com modalidade ou título secundário.
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
@@ -628,6 +661,7 @@ const fcOptions = computed(() => ({
return {
html: `
<div class="mc-fc-event">
${payBadgeHtml}
${titleLine}
${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
@@ -729,15 +763,23 @@ function goNext() { fcApi()?.next(); }
function goToday() { fcApi()?.today(); }
function setView(v) {
// Detecta saida da view 'lista' antes de trocar — se o user veio de
// lista, o refDate atual ta em (hoje - 1 ano) e ao mudar pra week/month
// o FullCalendar mantem esse refDate, fazendo a agenda parecer estar
// no ano passado. Snap pra hoje resolve. 2026-05-12.
const leavingLista = calendarView.value === 'lista' && v !== 'lista';
calendarView.value = v;
fcApi()?.changeView(VIEW_MAP[v]);
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez. Outras views mantém
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
if (v === 'lista') {
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez.
const umAnoAtras = new Date();
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
fcApi()?.gotoDate(umAnoAtras);
} else if (leavingLista) {
fcApi()?.today();
}
}
@@ -779,10 +821,12 @@ const tenantStore = useTenantStore();
// acessos diretos a M.x dispararam TypeError ao montar fora do layout.
const _feriadosFallback = ref([]);
const _feriadoFcEventsFallback = ref([]);
const _bloqueioFcEventsFallback = ref([]);
const _feriadosAnoFallback = ref(new Date().getFullYear());
const _workRulesFallback = ref([]);
const feriadosTodos = M?.feriados ?? _feriadosFallback;
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
const workRules = M?.workRules ?? _workRulesFallback;
@@ -2291,10 +2335,49 @@ defineExpose({
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
font-style: italic;
}
/* Sessão paga — barra esquerda verde no lugar da cor do commitment.
Espelho positivo do badge $ amber: pago = canal visual esquerdo,
pendente = canal direito, sem cobrança = neutro. !important porque
o FC seta border-color inline a partir do borderColor do evento. */
.ma-cal__fc :deep(.fc-event.ma-evt--paid) {
border-left-color: #10b981 !important; /* emerald-500 */
border-left-width: 4px !important;
}
.ma-cal__fc :deep(.fc-list-event.ma-evt--paid .fc-list-event-dot) {
border-color: #10b981 !important;
}
.ma-cal__fc :deep(.mc-fc-event) {
padding: 4px 6px;
color: var(--m-text);
font-family: inherit;
position: relative;
}
/* Badge "$ a receber" — canto superior direito do evento. Amarelo
amber, pequeno, sinaliza cobrança pendente sem competir com os
badges de status/modalidade. Renderizado só pra sessão+paciente
com paymentState !== 'paid'. */
.ma-cal__fc :deep(.mc-fc-event__paybadge) {
position: absolute;
top: 2px;
right: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 9999px;
background: #f59e0b;
color: #fff;
font-size: 0.6rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
z-index: 2;
}
.ma-cal__fc :deep(.mc-fc-event__paybadge .pi) {
font-size: 0.62rem;
font-weight: 700;
}
.ma-cal__fc :deep(.mc-fc-event__title) {
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra