# 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:

```sql
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.js` — `loadOccFinancialRecord` + `occFinancialRecord` ref
- `src/features/agenda/components/AgendaEventDialog.vue` — card lock/unlock + aviso pai
- `src/features/agenda/composables/useCommitmentServices.js:162` — `propagateToSerie` com filtro financial_records
- `src/features/agenda/composables/useAgendaEventComposer.js:91` — `editScopeOptions` 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.js` — `loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:817` — `onEditSeriesOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:837` — `onUpdateSeriesEvent`
- `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status
- `src/features/patients/composables/usePatientSessions.js:189` — `updateStatus` com materialização
- `src/features/agenda/components/AgendaEventDialog.vue` — props `occurrenceMode`, computeds `occurrenceIndex` / `occurrenceTotalSessions` / `headerMainLabel`
- `src/layout/melissa/MelissaLayout.vue:655` — `updateEventoStatus` do `MelissaEventoPanel`
- `src/layout/melissa/MelissaLayout.vue:2160` — 2º AgendaEventDialog empilhado
- `src/layout/melissa/MelissaAgenda.vue:244` — `VIEW_MAP.lista = 'listAll'`
