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
@@ -48,6 +48,25 @@ export function useAgendaEventActions({
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
// chargeMode (Opção C1, 2026-05-13): ref string com modo de cobrança.
// Valores: 'none' | 'session' (avulsa) | 'package' | 'per_session' (recorrente).
// O emit('save') leva chargeMode no payload; handler em useMelissaAgenda
// decide o que criar (financial_record | billing_contract | N events+records).
// Substituiu o boolean gerarCobrancaAoSalvar.
chargeMode,
// packageStyle (2026-05-14): só relevante em chargeMode='package'.
// Valores: 'upfront' (cria 1 financial_record total + materializa 1ª ocorrência)
// | 'saldo' (só billing_contract, sem financial_record imediato — Cliniko).
packageStyle,
// paymentMethod (refatorado 2026-05-16): forma de recebimento quando
// avulsa+session OU pacote+upfront. Valores: 'link' (Asaas, status pending)
// | 'pix' | 'dinheiro' | 'deposito' | 'cartao_maquininha'. Status do
// record é controlado pelo markPaidNow abaixo, não pela forma.
paymentMethod,
// markPaidNow (refatorado 2026-05-16): boolean. Quando true E método !== 'link',
// handler marca o financial_record como paid (paciente pagou na hora).
// Quando false, record nasce pending independente do método.
markPaidNow,
props,
emit
}) {
@@ -62,88 +81,88 @@ export function useAgendaEventActions({
const samePatientConflict = ref(null);
// ────────────────────────────────────────────────────────────────────
// 1. Watcher do form.status — confirma cancelar/remarcar via dialog
// e persiste no banco IMEDIATAMENTE. Reverte se cancelar.
// Antes vivia no .vue; testado em isolamento agora.
// 1. Watcher do form.status
// Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit
// `updateSeriesEvent` pro parent abrir o AgendaStatusChangeConfirmDialog
// (com regras de exceção, saldo de pacote, etc). Sem confirm.require
// aqui — o dialog do parent é a fonte canônica.
// Pra remarcado mantém path antigo (confirm.require simples).
// Se user cancelar o dialog: parent chama onReject pra reverter o form.
// ────────────────────────────────────────────────────────────────────
watch(
() => composer.form.value?.status,
async (newVal, oldVal) => {
if (_skipStatusWatch.value) return;
if (!composer.isEdit.value || !composer.form.value?.id) return;
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
const isStatusComDialog = ['realizado', 'faltou', 'cancelado'].includes(newVal);
const isRemarcado = newVal === 'remarcado';
if (!isStatusComDialog && !isRemarcado) return;
_prevStatus.value = oldVal;
const isCancelar = newVal === 'cancelado';
// Fase 5: emit pro parent abrir AgendaStatusChangeConfirmDialog.
// Parent decide o que fazer e chama onReject() se user cancelar.
if (isStatusComDialog) {
const formId = composer.form.value.id;
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
emit('updateSeriesEvent', {
id: isVirtual ? null : formId,
status: newVal,
recurrence_date:
composer.form.value.recurrence_date ||
composer.form.value.original_date ||
String(composer.form.value.inicio_em || '').slice(0, 10),
inicio_em: composer.form.value.inicio_em,
fim_em: composer.form.value.fim_em,
is_virtual: isVirtual,
// Form completo — handler usa pra resolver recurrence_id, billing_contract_id, etc
row: { ...composer.form.value },
// Callback pra reverter status no form se user cancelar o dialog do parent.
// _skipStatusWatch evita loop recursivo no watcher.
onReject: () => {
_skipStatusWatch.value = true;
composer.form.value.status = _prevStatus.value;
Promise.resolve().then(() => {
_skipStatusWatch.value = false;
});
}
});
return;
}
// Path legacy pra 'remarcado': confirm.require simples + UPDATE direto.
confirm.require({
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão',
message: isCancelar
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.'
: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh',
header: 'Remarcar sessão',
message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
icon: 'pi pi-refresh',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Não',
acceptSeverity: isCancelar ? 'danger' : 'warn',
acceptSeverity: 'warn',
accept: async () => {
try {
// Se o evento é ocorrência VIRTUAL de recorrência
// (id "rec::..." sem row real em agenda_eventos),
// delega pro parent — useMelissaAgenda.onUpdateSeriesEvent
// e AgendaTerapeutaPage.onUpdateSeriesEvent materializam
// a linha antes de aplicar status. Sem essa delegação,
// UPDATE direto em id virtual quebra com PostgreSQL
// "invalid input syntax for type uuid".
const formId = composer.form.value.id;
const isVirtual =
!!composer.form.value.is_occurrence ||
(typeof formId === 'string' && formId.startsWith('rec::'));
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
if (isVirtual) {
emit('updateSeriesEvent', {
id: null, // sem row real
id: null,
status: newVal,
recurrence_date:
composer.form.value.recurrence_date ||
composer.form.value.original_date ||
String(composer.form.value.inicio_em || '').slice(0, 10),
recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10),
inicio_em: composer.form.value.inicio_em,
fim_em: composer.form.value.fim_em,
is_virtual: true,
// Form completo do dialog — handler usa pra resolver
// recurrence_id/patient_id sem depender de dialogEventRow.
row: { ...composer.form.value }
});
toast.add({
severity: 'success',
summary: 'Status atualizado',
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
life: 3000
});
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
return;
}
const { data, error } = await supabase
.from('agenda_eventos')
.update({ status: newVal })
.eq('id', formId)
.select()
.single();
const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
if (error) throw error;
toast.add({
severity: 'success',
summary: 'Status atualizado',
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
life: 3000
});
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
emit('updated', data);
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível atualizar o status.',
life: 4000
});
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 });
composer.form.value.status = _prevStatus.value;
}
},
@@ -327,6 +346,24 @@ export function useAgendaEventActions({
editMode: emitEditMode,
recurrence_id: emitRecurrenceId,
original_date: emitOriginalDate,
// _occurrenceMode: flag pra distinguir save do 2o dialog empilhado
// (editar UMA ocorrencia) do save do dialog pai. Handler decide qual
// dialog fechar — sem isso, fechava sempre o pai. 2026-05-12.
_occurrenceMode: !!props.occurrenceMode,
// chargeMode (Opção C1, 2026-05-13): handler decide entre criar
// financial_record (avulsa+session), billing_contract (recorrente+package)
// ou materializar N ocorrências + N records (recorrente+per_session).
// UI no .vue garante valores válidos por modo.
chargeMode: chargeMode?.value ?? 'none',
// packageStyle (2026-05-14): handler em useMelissaAgenda usa pra
// decidir entre upfront (1 record total + materializa 1ª) ou
// saldo (só contrato).
packageStyle: packageStyle?.value ?? 'upfront',
// paymentMethod + markPaidNow (refatorado 2026-05-16): substituem
// o antigo paymentSettlement. Handler aplica payment_method (sempre)
// e status=paid+paid_at apenas quando markPaidNow=true && method!='link'.
paymentMethod: paymentMethod?.value ?? 'link',
markPaidNow: markPaidNow?.value === true,
// legado — mantido para compatibilidade
serie_id: props.eventRow?.serie_id ?? null,
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,