Files
Leonardo e95ed9b585 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>
2026-05-19 08:31:18 -03:00

11 KiB

Recorrência na Agenda

Como o sistema modela e exibe sessões recorrentes. Decisões arquiteturais importantes que não são óbvias só lendo o código.

Modelo de dados — "1 real + N-1 virtual"

Quando o user cria "4 sessões semanais", o sistema escreve só 2 rows no banco:

  1. 1 row em agenda_eventos — a primeira ocorrência, materializada. Tem recurrence_id apontando pra regra abaixo.
  2. 1 row em recurrence_rules — a "semente": start_date, type='weekly', interval=1, max_occurrences=4 (ou open_ended=true).

As sessões 2, 3, 4 NÃO existem no banco. São geradas em runtime por useRecurrence.loadAndExpand — chamado pelas páginas de agenda quando precisam exibir um range. ID virtual: rec::ruleId::originalDateISO.

Trade-off da escolha:

  • Cria recorrência infinita (open-ended) sem inflar o DB
  • Mudança na regra (preço, modalidade, etc) reflete em todas as ocorrências automaticamente
  • Toda exibição precisa chamar loadAndExpand no range visível
  • Edição de uma ocorrência específica exige materializar primeiro (criar a row real com recurrence_id + recurrence_date)

Quem expande virtuais (e quem não)

Expande:

  • AgendaTerapeutaPage (Rail) — loadAndExpand no range mensal + na busca
  • AgendaClinicaPage (Clínica) — mesma coisa, com tenant
  • useMelissaAgenda._reloadRange (Melissa FullCalendar) — expande no range visível
  • usePatientSessions.load — range -6mo a +12mo, filtra por patient_id
  • useMelissaEventos._fetchRange — expande no range pedido. Cobre widget "Hoje", mini-cal, fallback
  • useMelissaTodasSessoesPaciente.fetch — range -6mo a +12mo, filtra por patient_id

Antes de 2026-05-11 os 3 últimos NÃO expandiam — uma série semanal de 4 aparecia como 1. Comentário no código admitia "adicionar quando promover Melissa pra produção". Bug resolvido nesta data.

Cap do range — MAX_RANGE_DAYS = 730

useRecurrence.expandRules loga warning quando o range visível ultrapassa 730 dias (2 anos). É só warning, não bloqueia. A listAll view custom do MelissaAgenda usa exatamente duration: { years: 2 } pra bater no cap.

Materialização — "ao mudar status numa virtual"

UPDATE direto numa row com id = "rec::..." quebra com invalid input syntax for type uuid. Pra mudar status (cancelar, marcar realizada, etc) numa ocorrência virtual, é preciso:

  1. Buscar em agenda_eventos se já existe row materializada (recurrence_id + recurrence_date).
  2. Se sim, UPDATE status nela.
  3. Se não, INSERT nova row copiando campos da virtual + status novo.

Pattern central: useMelissaAgenda.onUpdateSeriesEvent(...) (e gêmeas em AgendaTerapeutaPage / AgendaClinicaPage). Aceita opcional row do chamador — quando o user clica direto no evento sem abrir o dialog antes, dialogEventRow está vazio e a função precisaria buscar a regra de outro lugar.

Caminhos que mudam status (e como chegam à materialização)

Onde Composable/Handler Comportamento virtual
MelissaEventoPanel (painel lateral do calendário) MelissaLayout.updateEventoStatus Detecta is_occurrence → delega M.onUpdateSeriesEvent({ row: ev, ... })
AgendaEventDialog SelectButton form.status (Cancelado/Remarcar) useAgendaEventActions watcher emit('updateSeriesEvent', { row, ... }) em vez de UPDATE direto
AgendaEventDialog pills da série useAgendaEventLifecycle.onPillStatusChange Já emitia desde sempre
MelissaPaciente Tab Agenda botões diretos usePatientSessions.updateStatus(ev, status) Aceita row inteira; se virtual, materializa internamente

Guard importante em onUpdateSeriesEvent: se recurrence_id resolver pra null (callerRow + dialogEventRow ambos sem ele), aborta com toast. Antes criava row órfã com patient_id: null aparecendo "Faltou sem nome" no calendário.

View listAll no MelissaAgenda

View custom (não built-in do FC) com duration: { years: 2 }. setView('lista') faz gotoDate(hoje - 1 ano) pra centrar passado + presente + futuro. Substituiu listWeek que mostrava só 7 dias.

Banner showRecurrenceHint aparece nas outras views (dia/semana/mês) quando há virtuais visíveis — botão "Ver na lista" troca pra listAll. Sem o banner, user não percebe que tem ocorrências fora do range.

Visual de evento inativo

normalizeEvent (useMelissaEventos.js + useMelissaAgenda.normalizeForMelissa) carrega paciente_status do JOIN. MelissaAgenda aplica classNames: ['ma-evt--inactive-patient'] quando 'Arquivado'|'Inativo' — CSS dá borda tracejada + opacidade 0.58 + itálico em list view. Mantém a cor do commitment pra não perder contexto.

Picker do AgendaEventDialog / V2: mostra TODOS os pacientes (Ativo > Inativo > Arquivado), nao-Ativos com Tag + disabled + tooltip. selectPaciente bloqueia non-Ativo como defesa em camadas.

Quando algo der errado

Se aparecer "sessão fantasma sem nome" no calendário, provavelmente é row órfã criada por materialização sem patient_id/recurrence_id. Query pra detectar:

SELECT id, inicio_em, status, patient_id, recurrence_id
FROM agenda_eventos
WHERE patient_id IS NULL
  AND recurrence_id IS NULL
  AND tipo = 'sessao'
  AND created_at > NOW() - INTERVAL '1 day';

Causa raiz já corrigida em 2026-05-11 (guard contra rid null em onUpdateSeriesEvent), mas o pattern de query é útil pra catch futuros.

Invariante de cobrança em séries — "cobrança emitida é imutável"

Padrão adotado (SimplePractice / TherapyNotes / Cliniko): financial_records em status pending/paid/overdue são imutáveis pelo dialog da agenda. Ajustes só via fluxo do Financeiro (cancelar + refaturar). Garante:

  • Trilha fiscal estável.
  • Paciente não vê valor "mágico" mudando.
  • Dashboards de MRR e projeção consistentes.

Como o sistema honra a invariante

1. Lock no occurrenceMode (AgendaEventDialog.vue):

  • Card "Sessão / Honorários" detecta occFinancialRecord via query financial_records filtrada por agenda_evento_id + status in ('pending','paid','overdue').
  • Se record existe → renderiza apenas AgendaEventoFinanceiroPanel + mensagem de lock + Tag de status. Select de billingType e botão "Editar itens" desaparecem.
  • Se record não existe (virtual ou materializado sem cobrança) → edição livre, marca services_customized=true ao salvar.
  • Card "Aplicar alterações em" também é ocultado quando há cobrança (mudanças estruturais não se aplicam — usuário só pode mexer em status/horário).

2. Filtro em propagateToSerie (useCommitmentServices.js):

  • Após filtrar eventos elegíveis (recurrence_id + opcionalmente fromDate + opcionalmente services_customized=false), faz 1 query batch em financial_records pra coletar agenda_evento_id lockados.
  • Remove esses IDs da lista de elegíveis antes de fazer delete + insert de commitment_services.
  • Resultado: editar template da regra nunca toca ocorrências cobradas, mesmo em escopo todos.

3. Aviso fixo no dialog pai (em isEdit && hasSerie):

  • Mensagem inline abaixo do AgendaEventoFinanceiroPanel: "Alterações de tipo ou serviços afetam apenas sessões futuras ainda não cobradas. Cobranças já emitidas permanecem inalteradas — para ajustá-las, acesse o Financeiro."

Opção todos_sem_excecao removida da UI

  • O nome confundia (sugeria "ignorar cobranças") quando na verdade era "ignorar customização operacional" (services_customized=true).
  • Backend mantém o caso pra compat, mas editScopeOptions agora só retorna 3 valores: somente_este, este_e_seguintes, todos.
  • Mercado consolidado (SimplePractice etc) não expõe override de customizações — admin que precisa reseta sessão-a-sessão.

Onde está cada peça

  • src/features/agenda/composables/useAgendaEventLifecycle.jsloadOccFinancialRecord + occFinancialRecord ref
  • src/features/agenda/components/AgendaEventDialog.vue — card lock/unlock + aviso pai
  • src/features/agenda/composables/useCommitmentServices.js:162propagateToSerie com filtro financial_records
  • src/features/agenda/composables/useAgendaEventComposer.js:91editScopeOptions com 3 valores
  • src/components/agenda/AgendaEventoFinanceiroPanel.vue — UI do fluxo Financeiro embarcado

2º dialog empilhado — edição de ocorrência (occurrenceMode)

Quando o user clica "Editar" em uma pill da lista "Recorrências Aplicadas", abre um segundo AgendaEventDialog empilhado por cima do principal. Ele compartilha o mesmo componente, mas com a prop occurrenceMode=true que muda comportamento:

  • Título: Pacote · X de Y Sessões (computa occurrenceIndex via currentRecurrenceDate + serieEvents) em vez do padrão Sessão do Pacote · {nome}.
  • Layout enxuto: renderiza apenas 4 cards na ordem: (1) Dados da Recorrência (read-only summary), (2) Status, (3) Horário, (4) Aplicar alterações em. Tudo o resto (paciente-hero, fields-grid, serie-panel, sessão/honorários, frequência, extras, resumo mobile) fica oculto via v-if="!occurrenceMode".
  • Escopo Aplicar alterações em: migrou do composer-right do dialog pai pra dentro do dialog de ocorrência. O pai não mostra mais esse card — pra mudar escopo, o user obrigatoriamente vai pela pill.
  • Horário editável: botão "Ajustar horário" não fica :disabled="isEdit" no occurrenceMode (no pai sim — data/horário do pacote inteiro é imutável após criação).

Stack relevante:

  • MelissaLayout.vue:2160 monta o 2º dialog passando :occurrenceMode="true" + eventRow={ ...row, recurrence_date, _is_virtual } via refs agendaOccDialog* (destructurados de useMelissaAgenda no setup — refs aninhados não auto-unwrap no template).
  • useMelissaAgenda.onEditSeriesOccurrence popula occDialogEventRow + abre occDialogOpen=true. Substituiu o pattern antigo de mutar dialogEventRow in-place (que trocava silenciosamente os dados do dialog atual).
  • useAgendaEventLifecycle.onPillEditClick emite editSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }).

Pendente replicar: Rail (AgendaTerapeutaPage) e Clínica (AgendaClinicaPage) ainda têm só o dialog principal — o 2º só existe no Melissa por enquanto.

Referências de código

  • src/features/agenda/composables/useRecurrence.jsloadAndExpand, expandRules, mergeWithStoredSessions, buildOccurrence
  • src/layout/melissa/composables/useMelissaAgenda.js:817onEditSeriesOccurrence
  • src/layout/melissa/composables/useMelissaAgenda.js:837onUpdateSeriesEvent
  • src/features/agenda/composables/useAgendaEventActions.js:65 — watcher do form.status
  • src/features/patients/composables/usePatientSessions.js:189updateStatus com materialização
  • src/features/agenda/components/AgendaEventDialog.vue — props occurrenceMode, computeds occurrenceIndex / occurrenceTotalSessions / headerMainLabel
  • src/layout/melissa/MelissaLayout.vue:655updateEventoStatus do MelissaEventoPanel
  • src/layout/melissa/MelissaLayout.vue:2160 — 2º AgendaEventDialog empilhado
  • src/layout/melissa/MelissaAgenda.vue:244VIEW_MAP.lista = 'listAll'