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>
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 row em
agenda_eventos— a primeira ocorrência, materializada. Temrecurrence_idapontando pra regra abaixo. - 1 row em
recurrence_rules— a "semente":start_date,type='weekly',interval=1,max_occurrences=4(ouopen_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
loadAndExpandno 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) —loadAndExpandno range mensal + na buscaAgendaClinicaPage(Clínica) — mesma coisa, com tenantuseMelissaAgenda._reloadRange(Melissa FullCalendar) — expande no range visívelusePatientSessions.load— range -6mo a +12mo, filtra porpatient_iduseMelissaEventos._fetchRange— expande no range pedido. Cobre widget "Hoje", mini-cal, fallbackuseMelissaTodasSessoesPaciente.fetch— range -6mo a +12mo, filtra porpatient_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:
- Buscar em
agenda_eventosse já existe row materializada (recurrence_id+recurrence_date). - Se sim, UPDATE status nela.
- 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
occFinancialRecordvia queryfinancial_recordsfiltrada poragenda_evento_id+ statusin ('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=trueao 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_recordspra coletaragenda_evento_idlockados. - Remove esses IDs da lista de elegíveis antes de fazer
delete + insertdecommitment_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
editScopeOptionsagora 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.js—loadOccFinancialRecord+occFinancialRecordrefsrc/features/agenda/components/AgendaEventDialog.vue— card lock/unlock + aviso paisrc/features/agenda/composables/useCommitmentServices.js:162—propagateToSeriecom filtro financial_recordssrc/features/agenda/composables/useAgendaEventComposer.js:91—editScopeOptionscom 3 valoressrc/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(computaoccurrenceIndexviacurrentRecurrenceDate+serieEvents) em vez do padrãoSessã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 docomposer-rightdo 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:2160monta o 2º dialog passando:occurrenceMode="true"+eventRow={ ...row, recurrence_date, _is_virtual }via refsagendaOccDialog*(destructurados deuseMelissaAgendano setup — refs aninhados não auto-unwrap no template).useMelissaAgenda.onEditSeriesOccurrencepopulaoccDialogEventRow+ abreoccDialogOpen=true. Substituiu o pattern antigo de mutardialogEventRowin-place (que trocava silenciosamente os dados do dialog atual).useAgendaEventLifecycle.onPillEditClickemiteeditSeriesOccurrence({ 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.js—loadAndExpand,expandRules,mergeWithStoredSessions,buildOccurrencesrc/layout/melissa/composables/useMelissaAgenda.js:817—onEditSeriesOccurrencesrc/layout/melissa/composables/useMelissaAgenda.js:837—onUpdateSeriesEventsrc/features/agenda/composables/useAgendaEventActions.js:65— watcher do form.statussrc/features/patients/composables/usePatientSessions.js:189—updateStatuscom materializaçãosrc/features/agenda/components/AgendaEventDialog.vue— propsoccurrenceMode, computedsoccurrenceIndex/occurrenceTotalSessions/headerMainLabelsrc/layout/melissa/MelissaLayout.vue:655—updateEventoStatusdoMelissaEventoPanelsrc/layout/melissa/MelissaLayout.vue:2160— 2º AgendaEventDialog empilhadosrc/layout/melissa/MelissaAgenda.vue:244—VIEW_MAP.lista = 'listAll'