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
@@ -34,6 +34,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
@@ -52,6 +53,50 @@ const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') :
// -------------------- feriados --------------------
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
// -------------------- bloqueios (background events cinza) --------------------
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
// Cobre dia-inteiro, janela horária e recorrência semanal. Não veta criação
// (só agendador público veta), mas sinaliza pro user via toast.
function bloqueioCobrindo(start, end) {
const arr = bloqueioRows?.value || [];
if (!arr.length || !start) return null;
const dStart = start instanceof Date ? start : new Date(start);
const dEnd = end instanceof Date ? end : new Date(end || start);
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
const dow = dStart.getDay();
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
const parseHM = (s) => {
if (!s) return null;
const [h, m] = String(s).split(':').map(Number);
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
};
for (const b of arr) {
if (!b) continue;
if (b.recorrente && b.dia_semana != null) {
if (Number(b.dia_semana) !== dow) continue;
} else {
const di = b.data_inicio;
const df = b.data_fim || b.data_inicio;
if (!di) continue;
if (isoDay < di || isoDay > df) continue;
}
const bhi = parseHM(b.hora_inicio);
const bhf = parseHM(b.hora_fim);
if (bhi == null || bhf == null) return b;
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
}
return null;
}
const bloqueioFcEvents = computed(() => {
const s = currentRange.value.start;
const e = currentRange.value.end;
if (!s || !e) return [];
return buildBloqueioEvents(s, e);
});
onMounted(async () => {
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (tid) loadFeriados(tid);
@@ -535,7 +580,7 @@ const allEvents = computed(() => {
.filter(Boolean);
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
return [...base, ...occEvents, ...feriadoFcEvents.value];
return [...base, ...occEvents, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
});
// -------------------- eventos fora da jornada --------------------
@@ -925,6 +970,15 @@ async function openDialogCreate({ ownerId, start, end }) {
}
async function onSlotSelect({ ownerId, start, end }) {
const bloqHit = bloqueioCobrindo(start, end);
if (bloqHit) {
toast.add({
severity: 'warn',
summary: 'Horário bloqueado',
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
life: 5000
});
}
await openDialogCreate({ ownerId, start, end });
}
@@ -937,6 +991,10 @@ async function onEventClick(info) {
if (!ev) return;
const ep = ev.extendedProps || {};
// Bloqueios/pausas são background events — ignorar click.
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
dialogEventRow.value = {
id: ep.isOccurrence ? null : ev.id || null,
owner_id: ep.owner_id,
@@ -1686,6 +1744,10 @@ async function _reloadRange() {
allMerged.push(...merged.filter((r) => r.is_occurrence));
}
_occurrenceRows.value = allMerged;
// Bloqueios (background events) — load assíncrono em paralelo. Recebe
// o array de owners da clínica pra agregar todos numa query batch.
loadBloqueios(ownerIds.value, start, end);
}
// Ocorrências virtuais geradas pelo useRecurrence