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
@@ -214,6 +214,109 @@ export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// buildBloqueioBackgroundEvents
// Renderiza rows de public.agenda_bloqueios como background events cinza
// no FullCalendar. Suporta:
// - Bloqueio de dia inteiro (hora_inicio/fim NULL) → background do dia todo
// - Bloqueio com janela horária → background no intervalo
// - Bloqueio recorrente semanal (recorrente=true + dia_semana 0-6) →
// repetido em todas as ocorrências do dow no range
// ─────────────────────────────────────────────────────────────────────────────
export function buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd) {
if (!Array.isArray(bloqueios) || bloqueios.length === 0) return [];
if (!rangeStart || !rangeEnd) return [];
const out = [];
const dayMs = 24 * 60 * 60 * 1000;
const startMs = startOfDay(rangeStart).getTime();
const endMs = rangeEnd.getTime();
for (const b of bloqueios) {
if (!b) continue;
const titulo = b.titulo || 'Bloqueio';
// Recorrente semanal: itera o range varrendo dias-da-semana iguais
if (b.recorrente && b.dia_semana != null) {
const dow = Number(b.dia_semana);
if (!Number.isFinite(dow) || dow < 0 || dow > 6) continue;
const hi = asTime(b.hora_inicio);
const hf = asTime(b.hora_fim);
for (let ts = startMs; ts < endMs; ts += dayMs) {
const d = new Date(ts);
if (d.getDay() !== dow) continue;
if (hi && hf) {
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
} else {
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
}
}
continue;
}
// Não recorrente: usa data_inicio e (opcional) data_fim
const di = _parseISODate(b.data_inicio);
if (!di) continue;
const df = _parseISODate(b.data_fim) ?? di;
// Range de dias do bloqueio (inclusive)
for (let cur = di.getTime(); cur <= df.getTime(); cur += dayMs) {
const d = new Date(cur);
// Filtra fora do range visível pra evitar lixo
if (d.getTime() + dayMs < startMs || d.getTime() > endMs) continue;
const hi = asTime(b.hora_inicio);
const hf = asTime(b.hora_fim);
if (hi && hf) {
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
} else {
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
}
}
}
return out;
}
function _makeBloqueioEvent(id, date, timeStart, timeEnd, titulo) {
const tag = id ?? `blq-${date.getTime()}-${timeStart}-${timeEnd}`;
return {
id: `blq-${tag}`,
title: titulo,
start: combineDateTimeISO(date, timeStart),
end: combineDateTimeISO(date, timeEnd),
display: 'background',
overlap: false,
backgroundColor: '#6b728033', // cinza ~20%
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
};
}
function _makeBloqueioDayEvent(id, date, titulo) {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const dateStr = `${yyyy}-${mm}-${dd}`;
const tag = id ?? `blq-${dateStr}`;
return {
id: `blq-day-${tag}`,
title: titulo,
start: dateStr,
allDay: true,
display: 'background',
overlap: false,
backgroundColor: '#6b728033',
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
};
}
function _parseISODate(s) {
if (!s) return null;
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
// ─────────────────────────────────────────────────────────────────────────────
// minutesToDuration / tituloFallback
// ─────────────────────────────────────────────────────────────────────────────