Add missing C7 modules and register C11 simplify follow-ups.

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 03:52:22 -03:00
parent 15c756bb10
commit 9efcf2832f
14 changed files with 865 additions and 43 deletions

View file

@ -15,7 +15,7 @@ alwaysApply: false
- **C3 Planos de Ação implementado (2026-07-18):** collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`.
- **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md).
- **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável.
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks.
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)).
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
- Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
@ -45,6 +45,7 @@ alwaysApply: false
- **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena.
- **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md).
- **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md).
- **Em 2026-07-19, C11 Escala e DRY pós-C7 registrado** (após `/simplify` do C7): feed O(n) condicional, loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths mutação/form, utilities de busca/label. Plano: [`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md).
## Decisões

View file

@ -129,6 +129,7 @@ flowchart TD
- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — precedente C6 + `campaignListUrl`
- Review `/simplify` 2026-07-18 (quality / reuse / performance) — origem das fases
- Review `/simplify` 2026-07-19 — débitos registrados nas fases pendentes acima
- Review `/simplify` 2026-07-19 (pós-entrega C7) — débitos maiores → **C11** / [escala-dry-pos-c7.md](escala-dry-pos-c7.md)
- `src/lib/contactSearchQuery.ts` — guard compartilhado client/server (entregue)
- `src/migrations/20260719_014906_action_plan_list_perf.ts` — contadores + índice parcial (entregue)
- `src/components/campaign/AsyncSearchCombobox.tsx` — shell assíncrono compartilhado (entregue)
@ -138,3 +139,7 @@ flowchart TD
- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW
- `src/migrations/20260718_222832_add_action_plan.ts` — índices atuais `status` / `start_at` separados
- AGENTS.md — `overrideAccess: false`, transações, naming, território Bahia
## Simplify (2026-07-19)
Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados no item **C11** do roadmap — [escala-dry-pos-c7.md](escala-dry-pos-c7.md).

View file

@ -0,0 +1,137 @@
# Escala e DRY pós-C7 (planos de ação / agenda)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C11)
Responsável: —
## Contexto
O C7 ([escala-dry-pos-c3.md](escala-dry-pos-c3.md)) entregou composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), shell `AsyncSearchCombobox` (+ `ContactCombobox` / `PrimaryContactCombobox` / `LeadershipCombobox`), leituras por aba no detalhe (`getActionPlanDetailSelect` + `taskProgress` + batch de autores na aba Atualizações), typeahead de liderança (`searchActionPlanLeadershipOptions`), short-circuit de hooks em toggle/append (`context.mutationKind`) e cleanup do `/simplify` (depth overview/tasks, `searchRef`, `fieldError`, remoção de exports mortos).
Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, planos com feed longo degradam leitura/append; loaders do detalhe ficam fragmentados em relação ao padrão de núcleos; e hot paths de edição continuam O(n) no array embutido.
1. **Feed de atualizações O(n) (leitura + escrita).** A aba Atualizações carrega o array `updates` inteiro (corpos até 4 000 chars) sem limite/cursor; `appendActionPlanUpdateRecord` faz read-modify-write do array completo. Escala linear com o tamanho do feed — principal cliff de performance da vertical de agenda.
2. **Toggle de tarefa O(n).** `toggleActionPlanTaskRecord` ainda faz `findByID` + `update` com o array `tasks` inteiro (locks + short-circuit de hooks ajudam, mas o RMW permanece).
3. **Selects do detalhe over-fetch em tasks/updates.** `actionPlanDetailContextSelect` inclui `coordinators`, `responsible`, `createdBy`, `description`, etc. nas abas que só precisam de header + conteúdo da aba — hidratação extra em `depth: 1`.
4. **Loader do detalhe fragmentado.** `planos/[slug]/page.tsx` encadeia `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`; núcleos já usam orquestrador único (`loadNucleusDetailPageData` / `loadNucleusActiveTabPageData`).
5. **Batch de nomes de autor duplicado.** `loadActionPlanUpdateAuthorNames` em `actionPlanDetailPageData.ts` repete o padrão de `nucleusUpdatePageData` / overview de núcleos (`campaignUser` `id in` → `Map<id, name>`).
6. **Form de edição hidrata todas as tarefas.** `getActionPlanEditPageData` usa `actionPlanFormSelect` + `depth: 1` sempre — latência cresce com contagem de tarefas/responsáveis.
7. **`serializeTasks` no hidden input a cada keystroke.** `ActionPlanTaskFields` chama `JSON.stringify` do array completo em todo render enquanto o usuário edita tarefas.
8. **Typeahead sem índice dedicado na busca de liderança.** `searchActionPlanLeadershipOptions` usa `contains` em `contact.*` aninhado; `pg_trgm` em `contact` já existe (C8) mas a query de liderança ainda não reutiliza o padrão otimizado de apoiadores.
9. **Lista de coordenadores sem paginação no form.** `getEligibleNucleusCoordinatorOptions` com `pagination: false` em novo/editar — O(staff) por page view (aceitável no MVP, débito se o time crescer).
10. **`mutationKind` sem tipo compartilhado.** Magic strings `'taskToggle' | 'appendUpdate'` em actions e `ActionPlan.ts` — risco de drift.
11. **DRY menor:** `searchContactComboboxOptions` ainda inline em `contactSearchActions.ts` (liderança já em `utilities/`); label de liderança duplicada entre `actionPlanLeadershipOptions` e `toActionPlanFormViewModel`; `firstValue` local em `actionPlanDetailTabUi.ts` (já existe em `campaignListUrl.ts`); loader combinado da lista `/campanha/planos` (precedente: `loadSupportersPageData` no C9).
**Explicitamente fora (revisores pediram skip no simplify / já coberto em outro item):**
- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md) Fase 3 (inclui `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`).
- **`pg_trgm` em `contact` (migration)** — C8 entregue; este plano só cobre adoção do padrão de busca na query de liderança se necessário.
- **Unificar `validateCampaignTerritoryFormData` ↔ `createCampaignTerritoryValidationHook`** — tipos de erro diferentes; permanece em [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo.
- **Renomear `NucleusTerritoryFields.tsx` → `CampaignTerritoryFields.tsx`** — fricção de descoberta; fill-in, não bloqueia escala.
- **View-model dedup form ↔ detalhe** — refactor grande; só se surgir terceiro consumidor.
- **Testes E2E da vertical** — fora do escopo deste item de escala/DRY.
## Objetivos
- Agenda (`/campanha/planos`) permanece responsiva com dezenas de atualizações e tarefas por plano — medir antes de migration; feed append-only só se o volume justificar.
- Detalhe e edição seguem o mesmo padrão de loaders que núcleos (orquestrador + tab data).
- Hot paths de mutação (toggle tarefa, append update) minimizam bytes lidos/escritos sem quebrar locks transacionais nem RBAC.
- Guardrails: `overrideAccess: false` com `user`; sem novo `Consent`; migrations só na Fase 1 condicional (`actionPlanUpdate`).
## Decisões travadas
- **Um item C11, cinco fases ordenadas.** Mesmo racional de A7/B5/C8–C10: um ID de roadmap, PRs por fase. Ordem: feed condicional (maior cliff) → loaders/selects (I/O) → mutações/form hot path → utilities DRY → fill-ins.
- **Dependência dura de C7** mergeado em `main` (código de abas, combobox, hooks). Não reabre Fases 1–5 do C7.
- **Fase 1 (feed) é condicional.** Só migration + collection `actionPlanUpdate` após evidência (ex.: planos com >N updates ou latência medida em produção/staging). Até lá: documentar limite soft e monitorar.
- **Fase 1 condicional respeita congelamento ~20/09** — se entrar perto da reta final, adiar para pós-eleição; Fases 2–4 não exigem migration.
- **Cortável** se a agenda permanecer pequena (poucos planos, feeds curtos). Fase 1 é a mais cortável (e a única com migration). Fases 2–3 são as mais valiosas se houver uso real da agenda antes de 16/08.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadActionPlanDetailPageData`, `loadCampaignUserNamesById`, `ACTION_PLAN_MUTATION_KIND`), strings visíveis em pt-BR inalteradas.
## Questões em aberto
- **Fase 1: threshold para extrair `actionPlanUpdate`?** **Recomendação:** 50 updates por plano ou p95 de append >500 ms em staging; até lá manter array embutido e só paginar leitura (limit + cursor) sem nova collection se bastar.
- **Fase 1: paginação só na leitura vs collection append-only?** **Recomendação:** primeiro limitar/paginar leitura na aba Atualizações (sem migration); collection só se append RMW virar gargalo medido.
- **Fase 3: SQL direto no toggle de tarefa?** **Recomendação:** não na primeira iteração; manter Local API + locks; reavaliar com drizzle na txn só se profiling mostrar necessidade (mesmo trade-off do C8).
- **Fase 2: `loadActionPlanDetailPageData` retorna `{ context, view }` ou só `view`?** **Recomendação:** `{ context, view }` espelhando núcleos; página deixa de encadear dois awaits públicos.
## Abordagem proposta
```mermaid
flowchart TD
C7["C7 Fases 1–5 ✓"] --> F2
F2["Fase 2 — Loaders + selects por aba<br/>+ loadCampaignUserNamesById"]
F2 --> F3["Fase 3 — Hot path form/mutação<br/>(serializeTasks, mutationKind)"]
F2 --> F4["Fase 4 — Utilities DRY<br/>(contact search, labels, firstValue)"]
F1["Fase 1 — Feed escala<br/>(paginação / actionPlanUpdate)"]
C7 --> F1
F1 -.condicional volume.-> F5["Fase 5 — Loader lista planos"]
F3 -.opcional.-> F5
```
### Fase 1 — Escala do feed de atualizações _(condicional)_
- **1a (sem migration):** limitar leitura na aba Atualizações (`limit` + ordenação por `createdAt` desc); UI “carregar mais”; `getActionPlanDetailSelect` pode omitir corpos antigos até expandir.
- **1b (com migration, se medido):** collection `actionPlanUpdate` append-only + relação `actionPlan` ↔ updates; migrar dados existentes; `appendActionPlanUpdateRecord` vira `create` na collection filha; manter snapshot/embed mínimo no pai se necessário para contadores.
- Teste int: append concorrente + leitura paginada; não regressar locks advisory.
### Fase 2 — Loaders, batching e selects por aba
- `loadCampaignUserNamesById(payload, user, ids)` em `src/utilities/` — reutilizar em `actionPlanDetailPageData.ts`, `nucleusUpdatePageData.ts` e overview de núcleos.
- `loadActionPlanDetailPageData(payload, user, slug, activeTab)` → `{ context, view }` unificando `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`.
- Refinar `getActionPlanDetailSelect`: header mínimo compartilhado + campos por aba (tasks: `tasks` + responsáveis das tarefas; updates: `updates` apenas; overview: relações + `taskDoneCount`/`taskTotal`).
- Opcional: `getActionPlanEditPageData` com `select`/`depth` por seção (tarefas lazy) — só se edição ficar lenta em staging.
### Fase 3 — Hot paths de mutação e form de tarefas
- Constante tipada `ACTION_PLAN_MUTATION_KIND` compartilhada entre `actionPlan.ts` actions e `ActionPlan.ts` hooks.
- `ActionPlanTaskFields`: evitar `JSON.stringify` em todo render — atualizar hidden `tasksJson` só no submit ou com debounce/`useDeferredValue`.
- Documentar limite soft de tarefas por plano se toggle continuar RMW.
### Fase 4 — Utilities e DRY menor
- `searchContactComboboxOptions(payload, user, query)` em `utilities/`; `contactSearchActions.ts` fica auth wrapper (paridade com liderança).
- `actionPlanLeadershipLabel(leadership)` único para search + `toActionPlanFormViewModel`.
- `actionPlanDetailTabUi.ts` importa `firstValue` de `campaignListUrl.ts`.
- Avaliar índice/query de liderança alinhada ao padrão C8/C9 (`contactSearchQuery` + `pg_trgm` onde aplicável).
### Fase 5 (opcional) — Loader da lista de planos
- `loadActionPlansPageData` combinando canonical URL + list (precedente `loadSupportersPageData`).
- Entra só se a lista ganhar filtros/overview pesados; cortável.
**Migration:** somente Fase 1b (condicional). Demais fases sem schema.
## Dependências
- **Dura:** C7 Escala e DRY pós-C3 — entregue (merge em `main` pendente do PR atual).
- **Suave:** C8 `pg_trgm` em `contact` — já em `main`; acelera typeahead se Fase 4 adotar o padrão.
- **Suave:** C10 `errorProps` — paralelo; não bloqueia C11 (escopos distintos).
- Reuso: `src/utilities/actionPlanPageData.ts`, `actionPlanDetailPageData.ts`, `actionPlanViewModels.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, `src/collections/ActionPlan.ts`, padrão `loadNucleusDetailPageData`, `campaignListUrl.ts`, `contactSearchQuery.ts`.
## Não escopo
- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md).
- **Demandas / relação `actionPlan` opcional** — C4.
- **Notificações de agenda** — D2.
- **Calendário visual / mapa de eventos** — fora do horizonte pré-16/08.
- **Unificação FormData ↔ hook de território Payload** — [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo.
- **Coordinator list paginada** — só entra se staff > ~50; até lá fill-in.
## Referências
- `docs/roadmap.md` — Trilha C, item C11; sequência Janela 2
- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — C7 entregue; feed O(n) registrado aqui
- [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — precedente C8 (`pg_trgm`)
- [escala-dry-pos-c8.md](escala-dry-pos-c8.md) — precedente C9/C10
- `src/utilities/actionPlanDetailPageData.ts` — batch autores + view por aba
- `src/utilities/actionPlanPageData.ts` — depth/select por aba
- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW
- `src/components/campaign/ActionPlanTaskFields.tsx` — `serializeTasks`
- `src/utilities/actionPlanLeadershipOptions.ts` — typeahead
- `src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts` — server actions de busca
- AGENTS.md — transações, `overrideAccess: false`, naming
## Simplify (2026-07-19)
Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados neste plano como item **C11** do roadmap.

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -76,11 +76,12 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **C2 Cadastro nominal de apoiadores** — collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md).
- **C3 Eventos / agenda de mobilização** — collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho (obrigatório ao sair de `rascunho`); access por `coordinators`/`leadership` (escopo `lideranca` só toggle `tasks.done` + append `updates`); transações via `withPayloadTransaction`; migration `20260718_222832_add_action_plan`. Sem `Consent` (dado interno de staff). [Plano](plans/eventos-agenda-mobilizacao.md).
- **C7 Escala e DRY pós-C3 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Feed O(n) condicional fora de escopo. [Plano](plans/escala-dry-pos-c3.md).
- **C7 Escala e DRY pós-C3 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Débitos pós-`/simplify` → C11. [Plano](plans/escala-dry-pos-c3.md).
- **C6 Escala e DRY pós-C2 — implementado e mesclado em `main` (2026-07-19)** — import bulk drizzle, token HMAC + `supporterImportBatch`, KPI aggregate, shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). Duas passagens `/simplify`; débitos maiores → C8. [Plano](plans/escala-dry-pos-c2.md).
- **C8 Escala e DRY pós-C6 — implementado e mesclado em `main` (2026-07-19)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md).
- **C9 Escala e DRY pós-C8 — implementado (2026-07-19)** — filtro unificado Payload↔SQL (`supporterListFilters`), `contactSearchQuery` no aggregate/lista, `loadSupportersPageData` (1× núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos componentes de form restantes. Passagem `/simplify`; débitos maiores → C10. [Plano](plans/escala-dry-pos-c8.md).
- **C10 Escala e DRY pós-C9 registrado (2026-07-19)** — dedup de access na lista de apoiadores (`req.context`), query única de núcleos do coordenador (`loadCoordinatorNucleusScope`), `errorProps` nos forms grandes, prefetch opcional na criação. [Plano](plans/escala-dry-pos-c9.md).
- **C11 Escala e DRY pós-C7 registrado (2026-07-19)** — feed de atualizações O(n) (paginação / `actionPlanUpdate` condicional), loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths de mutação/form (`serializeTasks`, `mutationKind` tipado), utilities de busca/label. [Plano](plans/escala-dry-pos-c7.md).
### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19)
@ -112,6 +113,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) |
| C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) |
| C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) |
| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) |
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
@ -159,6 +161,7 @@ flowchart TD
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY) ✓"]
C9["C9 Escala e DRY pós-C8<br/>(filtros unificados / loader / forms) ✓"]
C10["C10 Escala e DRY pós-C9<br/>(access dedup / scope / errorProps)"]
C11["C11 Escala e DRY pós-C7<br/>(feed escala / loaders / hot paths)"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
end
@ -205,6 +208,8 @@ flowchart TD
C6 --> C8
C8 --> C9
C9 --> C10
C7 --> C11
C10 -.forms DRY.-> C11
D1 --> D2
JUR -.chave de push.-> D2
DEPLOY -.engenharia entregue.-> O0DRY
@ -218,7 +223,7 @@ flowchart TD
A7 -.mesmo loader do baseline.-> E2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
### Sequência de execução por janela do calendário
@ -244,31 +249,32 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
| 11 | C7 Escala e DRY pós-C3 **(entregue 2026-07-19 — Fases 1–5; feed O(n) condicional fora de escopo)** | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 |
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 |
| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 |
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2, C11 |
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10, C11 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- |
| 17 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
| 18 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 |
| 19 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
| 20 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
| 21 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
| 22 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
| 23 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 24 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 |
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 |
| 20 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
| 21 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
| 22 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
| 23 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
| 24 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 25 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 25 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 26 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 27 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
| 28 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
| 26 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 27 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 28 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
| 29 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@ -278,7 +284,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -9,7 +9,10 @@ import {
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { searchActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
import {
searchActionPlanLeadershipOptions,
type ActionPlanLeadershipOption,
} from '@/utilities/actionPlanLeadershipOptions'
const CONTACT_OPTION_LIMIT = 20
@ -43,7 +46,7 @@ export const searchActionPlanContactOptions = async (
export const searchActionPlanLeadershipOptionsAction = async (
query: string,
): Promise<Array<{ id: number; label: string }>> => {
): Promise<ActionPlanLeadershipOption[]> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')

View file

@ -4,7 +4,7 @@ import { useActionState, useState } from 'react'
import Link from 'next/link'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import { LeadershipCombobox } from '@/components/campaign/LeadershipCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
@ -26,7 +26,6 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { Spinner } from '@/components/ui/Spinner'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
@ -79,7 +78,7 @@ export const ActionPlanFormFields = ({
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
: null,
)
const [leadership, setLeadership] = useState<{ id: number; label: string } | null>(
const [leadership, setLeadership] = useState<ActionPlanLeadershipOption | null>(
plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null,
)
@ -304,19 +303,12 @@ export const ActionPlanFormFields = ({
<Field data-invalid={Boolean(errorFor('leadership'))}>
<FieldLabel>Liderança vinculada</FieldLabel>
<AsyncSearchCombobox
<LeadershipCombobox
name="leadership"
label="Liderança vinculada"
value={leadership}
onChange={setLeadership}
search={async (query) => {
const results = await searchLeaderships(query)
return results.map((option) => ({ id: option.id, label: option.label }))
}}
emptyOptionLabel="Nenhuma"
dialogDescription="Busque lideranças engajadas por nome ou celular."
isQueryReady={isContactSearchQueryReady}
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
search={searchLeaderships}
/>
<FieldDescription>
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar

View file

@ -0,0 +1,198 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronsUpDownIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
export type AsyncSearchOption = {
id: number
label: string
}
type AsyncSearchComboboxProps = {
name?: string
label: string
value: AsyncSearchOption | null
onChange?: (option: AsyncSearchOption | null) => void
search: (query: string) => Promise<AsyncSearchOption[]>
emptyOptionLabel?: string
dialogDescription?: string
isQueryReady?: (query: string) => boolean
queryTooShortMessage?: string
pinnedOptions?: AsyncSearchOption[]
pinnedGroupHeading?: string
}
export const AsyncSearchCombobox = ({
name,
label,
value,
onChange,
search,
emptyOptionLabel = 'Nenhum',
dialogDescription = 'Digite para buscar.',
isQueryReady = () => true,
queryTooShortMessage = 'Digite para buscar.',
pinnedOptions = [],
pinnedGroupHeading = 'Sugestões',
}: AsyncSearchComboboxProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<AsyncSearchOption | null>(value)
const [options, setOptions] = useState<AsyncSearchOption[]>([])
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
const searchRef = useRef(search)
const isQueryReadyRef = useRef(isQueryReady)
searchRef.current = search
isQueryReadyRef.current = isQueryReady
useEffect(() => {
setSelected(value)
}, [value])
useEffect(() => {
if (!open) return
if (!isQueryReadyRef.current(query)) {
setOptions([])
setLoading(false)
setFailed(false)
return
}
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void searchRef.current(query.trim())
.then((nextOptions) => {
if (requestId.current !== currentRequestId) return
setOptions(nextOptions)
})
.catch(() => {
if (requestId.current !== currentRequestId) return
setFailed(true)
})
.finally(() => {
if (requestId.current === currentRequestId) setLoading(false)
})
}, 250)
return () => window.clearTimeout(timeout)
}, [open, query])
const choose = (option: AsyncSearchOption | null) => {
setSelected(option)
setOpen(false)
setQuery('')
onChange?.(option)
}
const visibleOptions = options.filter((option) => option.id !== selected?.id)
const visiblePinned = pinnedOptions.filter((option) => option.id !== selected?.id)
return (
<>
{name ? <input type="hidden" name={name} value={selected?.id ?? ''} /> : null}
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`${label}: ${selected?.label ?? emptyOptionLabel}`}
onClick={() => setOpen(true)}
>
<span className="truncate">{selected?.label ?? emptyOptionLabel}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={label}
description={dialogDescription}
showCloseButton
>
<Command shouldFilter={false} label={label}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar"
aria-label={label}
/>
<CommandList aria-busy={loading}>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Buscando…
</div>
) : failed ? (
<p className="py-6 text-center text-sm text-destructive">
Não foi possível concluir a busca.
</p>
) : (
<>
<CommandGroup heading="Seleção">
<CommandItem value="none" onSelect={() => choose(null)}>
{emptyOptionLabel}
</CommandItem>
</CommandGroup>
{selected ? (
<CommandGroup heading="Selecionado">
<CommandItem
value={`current-${selected.id}`}
data-checked
onSelect={() => choose(selected)}
>
<span className="truncate">{selected.label}</span>
</CommandItem>
</CommandGroup>
) : null}
{visiblePinned.length ? (
<CommandGroup heading={pinnedGroupHeading}>
{visiblePinned.map((option) => (
<CommandItem
key={option.id}
value={`pinned-${option.id}`}
onSelect={() => choose(option)}
>
<span className="truncate">{option.label}</span>
</CommandItem>
))}
</CommandGroup>
) : null}
{visibleOptions.length ? (
<CommandGroup heading="Resultados">
{visibleOptions.map((option) => (
<CommandItem
key={option.id}
value={`option-${option.id}`}
onSelect={() => choose(option)}
>
<span className="truncate">{option.label}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{!isQueryReady(query) ? queryTooShortMessage : 'Nenhum resultado encontrado.'}
</p>
)}
</>
)}
</CommandList>
</Command>
</CommandDialog>
</>
)
}

View file

@ -0,0 +1,244 @@
'use client'
import { useMemo } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import type { CampaignTerritoryFieldsState } from '@/components/campaign/useCampaignTerritoryFieldsState'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import { fieldError } from '@/utilities/campaignFormFields'
const ChipList = ({
values,
onRemove,
removeLabel,
}: {
values: string[]
onRemove: (value: string) => void
removeLabel: (value: string) => string
}) => (
<div className="flex flex-wrap gap-1">
{values.map((value) => (
<Badge key={value} asChild variant="secondary" className="h-8 gap-1 rounded-sm px-2.5 py-1">
<button type="button" onClick={() => onRemove(value)} aria-label={removeLabel(value)}>
<span>{value}</span>
<XIcon aria-hidden="true" className="size-3" />
</button>
</Badge>
))}
</div>
)
export const CampaignTerritoryCoreFields = ({
state,
idPrefix,
fieldErrors = {},
citySuggestions: citySuggestionsOverride,
}: {
state: CampaignTerritoryFieldsState
idPrefix: string
fieldErrors?: Record<string, string[]>
citySuggestions?: ReturnType<typeof buildTerritorySuggestions>['citySuggestions']
}) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const {
regions,
cities,
neighborhoods,
cityDraft,
regionDraft,
neighborhoodDraft,
regionError,
cityError,
neighborhoodError,
setCityDraft,
setRegionDraft,
setNeighborhoodDraft,
setRegionError,
setCityError,
setNeighborhoodError,
displayRegions,
regionsAreDerived,
neighborhoodsEnabled,
availableCityOptions,
availableRegionOptions,
addCity,
removeCity,
addRegion,
removeRegion,
commitNeighborhood,
removeNeighborhood,
} = state
const citySuggestions = useMemo(() => {
if (citySuggestionsOverride) return citySuggestionsOverride
return buildTerritorySuggestions({
cities,
regions: displayRegions,
tseZones: [],
}).citySuggestions
}, [citySuggestionsOverride, cities, displayRegions])
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
const regionFieldId = `${idPrefix}-lookup-a`
const cityFieldId = `${idPrefix}-lookup-b`
const neighborhoodFieldId = `${idPrefix}-neighborhoodDraft`
return (
<>
{cities.map((city) => (
<input key={`city-${city}`} type="hidden" name="cities" value={city} />
))}
{!regionsAreDerived
? regions.map((region) => (
<input key={`region-${region}`} type="hidden" name="regions" value={region} />
))
: null}
{neighborhoodsEnabled
? neighborhoods.map((neighborhood) => (
<input
key={`neighborhood-${neighborhood}`}
type="hidden"
name="neighborhoods"
value={neighborhood}
/>
))
: null}
<Field data-invalid={Boolean(visibleRegionError)} className="sm:col-span-2">
<FieldLabel htmlFor={regionFieldId}>Territórios de identidade</FieldLabel>
{displayRegions.length ? (
<ChipList
values={displayRegions}
onRemove={removeRegion}
removeLabel={(region) => `Remover território ${region}`}
/>
) : null}
{regionsAreDerived ? (
<FieldDescription>
Derivados automaticamente dos municípios selecionados. Remova municípios para editar
territórios manualmente.
</FieldDescription>
) : (
<StrictCombobox
id={regionFieldId}
options={availableRegionOptions}
value={regionDraft}
onValueChange={(nextRegion) => {
if (nextRegion) addRegion(nextRegion)
else setRegionDraft('')
}}
onInvalid={() => {
setRegionError('Selecione um território de identidade válido da Bahia.')
}}
error={visibleRegionError}
/>
)}
{visibleRegionError ? (
<FieldError id={`${regionFieldId}-error`}>{visibleRegionError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor={cityFieldId}>Municípios</FieldLabel>
<TerritorySuggestionChips
kind="city"
suggestions={citySuggestions}
onAccept={(suggestion) => addCity(suggestion.city)}
/>
{cities.length ? (
<ChipList
values={cities}
onRemove={removeCity}
removeLabel={(city) => `Remover município ${city}`}
/>
) : null}
<StrictCombobox
id={cityFieldId}
options={availableCityOptions}
value={cityDraft}
onValueChange={(nextCity) => {
if (nextCity) addCity(nextCity)
else setCityDraft('')
}}
onInvalid={() => {
setCityError('Selecione um município válido da Bahia.')
}}
error={visibleCityError}
/>
<FieldDescription>
Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só
ficam disponíveis com exatamente um município.
</FieldDescription>
{visibleCityError ? (
<FieldError id={`${cityFieldId}-error`}>{visibleCityError}</FieldError>
) : null}
</Field>
<Field
data-disabled={!neighborhoodsEnabled}
data-invalid={Boolean(visibleNeighborhoodError)}
className="sm:col-span-2"
>
<FieldLabel htmlFor={neighborhoodFieldId}>Bairros</FieldLabel>
{neighborhoodsEnabled && neighborhoods.length ? (
<ChipList
values={neighborhoods}
onRemove={removeNeighborhood}
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
/>
) : null}
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
<InputGroupInput
id={neighborhoodFieldId}
value={neighborhoodDraft}
disabled={!neighborhoodsEnabled}
placeholder={
neighborhoodsEnabled
? 'Digite um bairro e pressione Enter'
: 'Selecione exatamente um município para informar bairros'
}
autoComplete="address-level3"
aria-invalid={Boolean(visibleNeighborhoodError)}
aria-describedby={
visibleNeighborhoodError
? `${idPrefix}-neighborhoods-error`
: `${idPrefix}-neighborhoods-description`
}
className="min-w-48"
onChange={(event) => {
setNeighborhoodDraft(event.target.value)
setNeighborhoodError(undefined)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitNeighborhood(neighborhoodDraft)
} else if (
event.key === 'Backspace' &&
!neighborhoodDraft &&
neighborhoods.length > 0
) {
removeNeighborhood(neighborhoods[neighborhoods.length - 1]!)
}
}}
onBlur={() => commitNeighborhood(neighborhoodDraft)}
/>
</InputGroup>
<FieldDescription id={`${idPrefix}-neighborhoods-description`}>
Use Enter para adicionar. Disponível somente quando há exatamente um município.
</FieldDescription>
{visibleNeighborhoodError ? (
<FieldError id={`${idPrefix}-neighborhoods-error`}>{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
</>
)
}

View file

@ -0,0 +1,40 @@
'use client'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
type LeadershipComboboxProps = {
name?: string
label: string
value: ActionPlanLeadershipOption | null
search: (query: string) => Promise<ActionPlanLeadershipOption[]>
emptyOptionLabel?: string
onChange?: (option: ActionPlanLeadershipOption | null) => void
}
export const LeadershipCombobox = ({
name,
label,
value,
search,
emptyOptionLabel = 'Nenhuma',
onChange,
}: LeadershipComboboxProps) => (
<AsyncSearchCombobox
name={name}
label={label}
value={value ? { id: value.id, label: value.label } : null}
emptyOptionLabel={emptyOptionLabel}
dialogDescription="Busque lideranças engajadas por nome ou celular."
isQueryReady={isContactSearchQueryReady}
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
search={async (query) => {
const results = await search(query)
return results.map((option) => ({ id: option.id, label: option.label }))
}}
onChange={(option) =>
onChange?.(option ? { id: option.id, label: option.label } : null)
}
/>
)

View file

@ -1,7 +1,5 @@
'use client'
import { useRef } from 'react'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import type {
NucleusPrimaryContactPageData,
@ -22,9 +20,6 @@ export const PrimaryContactCombobox = ({
current,
search,
}: PrimaryContactComboboxProps) => {
const contactsById = useRef(new Map<number, PrimaryContactOption>())
if (current) contactsById.current.set(current.id, current)
return (
<AsyncSearchCombobox
name={name}
@ -34,8 +29,6 @@ export const PrimaryContactCombobox = ({
dialogDescription="Busque entre as lideranças engajadas deste núcleo."
search={async (query) => {
const result = await search(query)
if (result.current) contactsById.current.set(result.current.id, result.current)
for (const contact of result.options) contactsById.current.set(contact.id, contact)
const limit = result.current ? 99 : 100
return result.options.slice(0, limit).map((contact) => ({
id: contact.id,

View file

@ -0,0 +1,148 @@
'use client'
import { useMemo, useState } from 'react'
import { territoriesForCities } from '@/lib/bahiaTerritories'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
allMunicipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
export type CampaignTerritoryValues = {
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
}
export const useCampaignTerritoryFieldsState = (values?: CampaignTerritoryValues) => {
const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
const [neighborhoods, setNeighborhoods] = useState(() =>
dedupeTrimmedStrings(values?.neighborhoods ?? []),
)
const [cityDraft, setCityDraft] = useState('')
const [regionDraft, setRegionDraft] = useState('')
const [neighborhoodDraft, setNeighborhoodDraft] = useState('')
const [regionError, setRegionError] = useState<string>()
const [cityError, setCityError] = useState<string>()
const [neighborhoodError, setNeighborhoodError] = useState<string>()
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
const displayRegions = cities.length > 0 ? derivedRegions : regions
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const availableCityOptions = useMemo(() => {
const selected = new Set(cities)
return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value))
}, [cities])
const availableRegionOptions = useMemo(() => {
const selected = new Set(regions)
return territoryComboboxOptions.filter((option) => !selected.has(option.value))
}, [regions])
const addCity = (nextCity: string) => {
if (!nextCity) return
if (cities.length >= MAX_NUCLEUS_CITIES) {
setCityError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`)
setCityDraft('')
return
}
setCities((current) => {
const next = dedupeTrimmedStrings([...current, nextCity])
if (next.length !== 1) setNeighborhoods([])
return next
})
setCityDraft('')
setCityError(undefined)
setRegionError(undefined)
}
const removeCity = (city: string) => {
setCities((current) => {
const next = current.filter((item) => item !== city)
if (next.length !== 1) setNeighborhoods([])
return next
})
setCityError(undefined)
}
const addRegion = (nextRegion: string) => {
if (!nextRegion || regionsAreDerived) return
if (regions.length >= MAX_NUCLEUS_REGIONS) {
setRegionError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`)
setRegionDraft('')
return
}
setRegions((current) => dedupeTrimmedStrings([...current, nextRegion]))
setRegionDraft('')
setRegionError(undefined)
}
const removeRegion = (region: string) => {
if (regionsAreDerived) return
setRegions((current) => current.filter((item) => item !== region))
setRegionError(undefined)
}
const commitNeighborhood = (value: string) => {
if (!neighborhoodsEnabled) {
setNeighborhoodDraft('')
return
}
const trimmed = value.trim()
if (!trimmed) {
setNeighborhoodDraft('')
return
}
if (neighborhoods.length >= MAX_NUCLEUS_NEIGHBORHOODS) {
setNeighborhoodError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`)
setNeighborhoodDraft('')
return
}
setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed]))
setNeighborhoodDraft('')
setNeighborhoodError(undefined)
}
const removeNeighborhood = (neighborhood: string) => {
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
}
return {
regions,
cities,
neighborhoods,
cityDraft,
regionDraft,
neighborhoodDraft,
regionError,
cityError,
neighborhoodError,
setCityDraft,
setRegionDraft,
setNeighborhoodDraft,
setRegionError,
setCityError,
setNeighborhoodError,
displayRegions,
regionsAreDerived,
neighborhoodsEnabled,
availableCityOptions,
availableRegionOptions,
addCity,
removeCity,
addRegion,
removeRegion,
commitNeighborhood,
removeNeighborhood,
}
}
export type CampaignTerritoryFieldsState = ReturnType<typeof useCampaignTerritoryFieldsState>

View file

@ -0,0 +1,56 @@
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import {
toActionPlanDetailViewModel,
type ActionPlanDetailViewModel,
} from '@/utilities/actionPlanViewModels'
import { relationshipId } from '@/utilities/relationship'
import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData'
const loadActionPlanUpdateAuthorNames = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
updates: NonNullable<ActionPlan['updates']>,
): Promise<Map<number, string>> => {
const authorIds = [
...new Set(
updates
.map((update) => relationshipId(update.author))
.filter((id): id is number => id !== null),
),
]
if (authorIds.length === 0) return new Map()
const result = await payload.find({
collection: 'campaignUser',
where: { id: { in: authorIds } },
depth: 0,
pagination: false,
select: { name: true },
user,
overrideAccess: false,
})
return new Map(result.docs.map((author) => [author.id, author.name]))
}
export const getActionPlanDetailPageData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
context: AccessibleActionPlanContext,
activeTab: ActionPlanDetailTab,
): Promise<ActionPlanDetailViewModel> => {
if (activeTab === 'updates' && context.document.updates?.length) {
const authorNames = await loadActionPlanUpdateAuthorNames(
payload,
user,
context.document.updates,
)
return toActionPlanDetailViewModel(context.document, activeTab, authorNames)
}
return toActionPlanDetailViewModel(context.document, activeTab)
}

View file

@ -52,7 +52,6 @@ export const searchActionPlanLeadershipOptions = async (
},
depth: 1,
limit: LEADERSHIP_SEARCH_LIMIT,
sort: 'id',
select: {
contact: true,
supportStatus: true,

View file

@ -272,7 +272,7 @@ const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] =>
doneAt: task.doneAt ?? null,
}))
export const mapActionPlanUpdates = (
const mapActionPlanUpdates = (
plan: ActionPlan,
authorNamesById: ReadonlyMap<number, string> = new Map(),
): ActionPlanUpdateViewModel[] =>