diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 0627fbc6..6f9a4ac1 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -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):** débitos de escala/DRY pós-C3 do `/simplify` (território/contato compostos, helpers FormData/URL, leituras por aba, RMW/índice) — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); não bloqueia o MVP da agenda.
+- **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.
- **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).
diff --git a/docs/plans/escala-dry-pos-c3.md b/docs/plans/escala-dry-pos-c3.md
index 426d9bf9..b306816e 100644
--- a/docs/plans/escala-dry-pos-c3.md
+++ b/docs/plans/escala-dry-pos-c3.md
@@ -1,6 +1,6 @@
# Escala e DRY pós-C3 (planos de ação)
-Status: em andamento (parcial 2026-07-19)
+Status: entregue (2026-07-19)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C7)
Responsável: —
@@ -19,24 +19,9 @@ O C3 ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) entregou a
Este plano é o registro canônico desses follow-ups. Sem ele, a agenda funciona em volume baixo, mas o DRY com núcleos e o custo por plano crescem com o feed de atualizações e a lista de "Próximos".
-**Entregue parcialmente (2026-07-19):** primeira fatia do C7 + cleanup do `/simplify` do mesmo dia.
+**Entregue (2026-07-19):** Fases 1–5 completas — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), shell `AsyncSearchCombobox` (Contact + Primary + liderança), helpers FormData/URL, leituras por aba + `taskProgress` no detalhe + typeahead de liderança, short-circuit de hooks em toggle/append. Feed O(n) / collection `actionPlanUpdate` permanece condicional (fora de escopo).
-- **Fase 3 (parcial):** `src/utilities/campaignTerritoryFormData.ts` (`parseCampaignTerritoryFields` / `validateCampaignTerritoryFormData`); `nucleusFormData.ts` e `actionPlanFormData.ts` passam a consumir o helper compartilhado. `actionPlanUi.ts` já consome `campaignListUrl` (C6 F2 — não era débito aberto).
-- **Fase 4 (parcial):** colunas derivadas `taskTotal` / `taskDoneCount` no `beforeChange` de `ActionPlan.ts`; migration `20260719_014906_action_plan_list_perf` (backfill + índice parcial `action_plan_upcoming_start_at_idx`); lista usa contadores em `actionPlanListSelect` / `toActionPlanListViewModel` (sem hidratar `tasks`).
-- **Fase 4 (parcial):** busca de contato com mínimo de 2 caracteres/dígitos — `src/lib/contactSearchQuery.ts` (client + server); `ContactCombobox.tsx` + `contactSearchActions.ts`.
-- **Fase 5 (parcial):** `toggleActionPlanTaskRecord` / `appendActionPlanUpdateRecord` adquirem `acquireTextAdvisoryLocks` (`action-plan:${planId}`) e `findByID` com `select` mínimo (`tasks` / `updates`).
-- **Fase 1 (parcial):** export principal renomeado para `CampaignTerritoryFields`; `ActionPlanForm.tsx` atualizado (alias `NucleusTerritoryFields` removido no simplify — sem importadores restantes).
-
-**Débitos importantes do `/simplify` 2026-07-19 que ficaram de fora** (registrados abaixo nas fases correspondentes):
-
-1. **Short-circuit de hooks no toggle/append** — `setCanonicalActionPlanSlug` e `createCampaignTerritoryValidationHook` ainda rodam em todo `payload.update`, mesmo quando o payload só toca `tasks` ou `updates`; amplifica custo do hot path (Fase 5).
-2. **Composição territorial** — `NucleusTerritoryAndZonesFields` ainda duplica ~300 linhas de `CampaignTerritoryFields` (Fase 1).
-3. **Shell assíncrono de contato** — `ContactCombobox` vs `PrimaryContactCombobox` sem `AsyncContactCombobox` compartilhado (Fase 2).
-4. **Leituras por aba + typeahead de liderança** — detalhe/form ainda carregam arrays completos; prefetch de lideranças não virou search lazy (Fase 4).
-5. **`taskProgress` no detalhe** — overview do plano ainda filtra `tasks` inline em vez de reutilizar o padrão da lista (Fase 4).
-6. **Validadores de território duplicados** — `validateCampaignTerritoryFormData` (FormData/`FormDataBoundaryError`) vs `createCampaignTerritoryValidationHook` (Payload/`APIError`); unificar exige refactor transversal (fora do escopo deste item — ver nota em "Não escopo").
-7. **Feed de updates O(n)** — append continua read-modify-write do array inteiro; collection `actionPlanUpdate` só se medir volume (Fase 5, condicional).
-8. **`pg_trgm` em busca de contato** — já registrado no C8 ([escala-dry-pos-c6.md](escala-dry-pos-c6.md) Fase 2); não duplicar aqui.
+**Primeira fatia (2026-07-19):** FormData território, contadores lista + migration `20260719_014906_action_plan_list_perf`, locks advisory, `contactSearchQuery`, rename `CampaignTerritoryFields`.
## Objetivos
@@ -83,40 +68,40 @@ flowchart TD
C6["C6 F2 campaignListUrl"] -.suave.-> F3
```
-### Fase 1 — Território UI composto _(pendente)_
+### Fase 1 — Território UI composto _(entregue)_
- Extrair o bloco regions/cities/neighborhoods/locality/notes (+ chips) para um core compartilhado (hoje duplicado em `NucleusTerritoryFields.tsx` ≈ `NucleusTerritoryAndZonesFields.tsx`).
- **`NucleusTerritoryAndZonesFields`**: compõe o core + `TseZoneInput` + sugestões de ZE (`buildTerritorySuggestions` / `outsideZones`).
- **`ActionPlanForm`**: continua importando só o core (sem ZE).
-- **Entregue:** rename `CampaignTerritoryFields` + consumo no form de plano.
+- **Entregue:** rename `CampaignTerritoryFields` + consumo no form de plano; core `useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`.
-### Fase 2 — Contact picker único _(pendente)_
+### Fase 2 — Contact picker único _(entregue)_
- Shell assíncrono (debounce, `requestId`, CommandDialog, loading/erro) a partir de `ContactCombobox.tsx`.
- **`PrimaryContactCombobox`**: adapta `{ current, options }` / search de inteligência de núcleo para o shell (sem segundo Dialog).
- Estado controlado (`value` + `onChange`); eliminar espelho pai/filho que o simplify apontou.
-- **Entregue:** helper `contactSearchQuery` (mín. 2 chars) — não substitui o shell compartilhado.
+- **Entregue:** `AsyncSearchCombobox` + adaptadores; helper `contactSearchQuery` (mín. 2 chars).
-### Fase 3 — Helpers FormData / URL _(parcialmente entregue)_
+### Fase 3 — Helpers FormData / URL _(entregue)_
- **`validateCampaignTerritoryFormData` / `parseCampaignTerritoryFields`**: ✅ extraído para `campaignTerritoryFormData.ts`; `nucleusFormData.ts` e `actionPlanFormData.ts` consomem.
- **URL de lista:** ✅ `actionPlanUi.ts` já consome `campaignListUrl.ts` (C6 F2).
- Opcional: `actionPlanDetailTabUi` parametrizado com o helper genérico de tabs do detalhe de núcleo (só se o PR já tocar URL).
-### Fase 4 — Leituras _(parcialmente entregue)_
+### Fase 4 — Leituras _(entregue)_
-- **`[slug]/page.tsx`**: selects por aba (overview sem arrays; tarefas sem `updates`; atualizações com `depth: 0` + batch de autores) — espelhar o padrão de loaders por aba do detalhe de núcleo. _(pendente)_
+- **`[slug]/page.tsx`**: selects por aba via `getActionPlanDetailSelect` + `getActionPlanDetailPageData` (overview sem arrays; tarefas com `depth: 1`; atualizações com `depth: 0` + batch de autores).
- **Lista:** ✅ `taskDoneCount` / `taskTotal` + `actionPlanListSelect` sem `tasks`.
-- **`getActionPlanLeadershipOptions` → search:** lazy no open do campo; `limit` baixo; sem `depth: 1` em massa. _(pendente)_
+- **`getActionPlanLeadershipOptions` → search:** ✅ `searchActionPlanLeadershipOptions` + `AsyncSearchCombobox` no form.
- **`ContactCombobox`:** ✅ não fetch com query < 2 chars (`contactSearchQuery.ts`).
-- **Detalhe:** expor `taskProgress` no view model do detalhe e substituir filtro inline de `tasks` na overview. _(pendente — débito do simplify)_
+- **Detalhe:** ✅ `taskProgress` no view model da overview (contadores, não filtro inline de `tasks`).
-### Fase 5 — Escritas + índice _(parcialmente entregue)_
+### Fase 5 — Escritas + índice _(entregue; feed O(n) condicional)_
- **`toggleActionPlanTaskRecord` / `appendActionPlanUpdateRecord`:** ✅ locks advisory + `select` mínimo no `findByID`.
-- **Short-circuit de hooks:** em `setCanonicalActionPlanSlug` e `validateActionPlanTerritory`, retornar cedo quando `data` só contém `tasks` ou `updates` (via `context` explícito nas actions). _(pendente — débito de performance do simplify)_
+- **Short-circuit de hooks:** ✅ `context.mutationKind: 'taskToggle' | 'appendUpdate'` nas actions; hooks `beforeValidate` retornam cedo.
- **Migration** `action_plan_list_perf`: ✅ contadores + índice parcial `action_plan_upcoming_start_at_idx` (`20260719_014906_action_plan_list_perf`).
-- **Feed O(n):** monitorar tamanho de `updates`; só então collection `actionPlanUpdate` append-only. _(condicional)_
+- **Feed O(n):** monitorar tamanho de `updates`; só então collection `actionPlanUpdate` append-only. _(condicional — não implementado)_
**Migration:** Fases 1–3 sem schema. Migration `20260719_014906_action_plan_list_perf` ✅ entregue (contadores + índice parcial). Sem Consent novo.
@@ -146,8 +131,8 @@ flowchart TD
- Review `/simplify` 2026-07-19 — débitos registrados nas fases pendentes acima
- `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/NucleusTerritoryFields.tsx` / `NucleusTerritoryAndZonesFields.tsx`
-- `src/components/campaign/ContactCombobox.tsx` / `PrimaryContactCombobox.tsx`
+- `src/components/campaign/AsyncSearchCombobox.tsx` — shell assíncrono compartilhado (entregue)
+- `src/components/campaign/useCampaignTerritoryFieldsState.ts` / `CampaignTerritoryCoreFields.tsx` — core territorial (entregue)
- `src/utilities/actionPlanFormData.ts` / `nucleusFormData.ts` / `actionPlanUi.ts`
- `src/utilities/actionPlanPageData.ts` / `actionPlanViewModels.ts` / `actionPlanLeadershipOptions.ts`
- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f7b24dee..8e9eefc6 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; 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 (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`; 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,7 +76,7 @@ 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 — parcial (2026-07-19)** — primeira fatia: `campaignTerritoryFormData`, contadores `taskTotal`/`taskDoneCount` + migration `20260719_014906_action_plan_list_perf`, locks advisory + `select` mínimo em toggle/append, `contactSearchQuery` (mín. 2 chars), rename `CampaignTerritoryFields`. Débitos do `/simplify` 2026-07-19 (composição territorial, shell de contato, leituras por aba, short-circuit de hooks, `taskProgress` no detalhe) registrados no plano — Fases 1–2 e restante de 4–5 pendentes. [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`. Feed O(n) condicional fora de escopo. [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).
@@ -155,7 +155,7 @@ flowchart TD
C2["C2 Cadastro nominal de apoiadores ✓"]
C6["C6 Escala e DRY pós-C2
(import/listas/forms) ✓"]
C3["C3 Eventos / agenda de mobilização ✓"]
- C7["C7 Escala e DRY pós-C3
(parcial: contadores/locks/FormData)"]
+ C7["C7 Escala e DRY pós-C3
(território/contato/leituras/hooks) ✓"]
C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY) ✓"]
C9["C9 Escala e DRY pós-C8
(filtros unificados / loader / forms) ✓"]
C10["C10 Escala e DRY pós-C9
(access dedup / scope / errorProps)"]
@@ -218,7 +218,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, 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), C7 (após C3; suave: C6 F2 ✓), 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) 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
@@ -241,9 +241,9 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
-| 11 | C7 Escala e DRY pós-C3 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
+| 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 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 |
+| 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 |
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx
index c1a31051..62ec3c1a 100644
--- a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx
@@ -2,10 +2,12 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import { notFound, redirect } from 'next/navigation'
-import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
+import {
+ searchActionPlanContactOptions,
+ searchActionPlanLeadershipOptionsAction,
+} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
-import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
@@ -28,13 +30,12 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
if (user.role === 'lideranca') redirect('/campanha/planos')
if (!slug) notFound()
- const [view, coordinators, leadershipOptions] = await Promise.all([
+ const [view, coordinators] = await Promise.all([
getActionPlanEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActionPlanNotFoundError) notFound()
throw error
}),
getEligibleNucleusCoordinatorOptions(payload, user),
- getActionPlanLeadershipOptions(payload, user),
])
return (
@@ -51,9 +52,9 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
plan={view}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
- leadershipOptions={leadershipOptions}
submitLabel="Salvar alterações"
searchContacts={searchActionPlanContactOptions}
+ searchLeaderships={searchActionPlanLeadershipOptionsAction}
/>
)
diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
index 9e5e2a8a..babdb5c6 100644
--- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx
@@ -20,9 +20,9 @@ import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
- getActionPlanDetailPageData,
resolveAccessibleActionPlanContext,
} from '@/utilities/actionPlanPageData'
+import { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData'
import {
getActionPlanDetailTabRedirect,
resolveActionPlanDetailTab,
@@ -49,11 +49,13 @@ export default async function ActionPlanDetailPage({
if (!slug) notFound()
const activeTab = resolveActionPlanDetailTab(query)
- const context = await resolveAccessibleActionPlanContext(payload, user, slug).catch((error) => {
- if (error instanceof ActionPlanNotFoundError) notFound()
- throw error
- })
- const view = getActionPlanDetailPageData(context)
+ const context = await resolveAccessibleActionPlanContext(payload, user, slug, activeTab).catch(
+ (error) => {
+ if (error instanceof ActionPlanNotFoundError) notFound()
+ throw error
+ },
+ )
+ const view = await getActionPlanDetailPageData(payload, user, context, activeTab)
const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
@@ -159,8 +161,7 @@ export default async function ActionPlanDetailPage({
Nenhum coordenador vinculado.
)}
- {view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas
- concluídas
+ {view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
{view.createdByName ? (
Criado por {view.createdByName}
diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts
index b1097017..e408d9cd 100644
--- a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts
+++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts
@@ -3,23 +3,19 @@
import config from '@payload-config'
import { getPayload } from 'payload'
+import type { ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import {
isContactSearchQueryReady,
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import { getCampaignUser } from '@/utilities/campaignAuth'
-
-export type ActionPlanContactOption = {
- id: number
- name: string
- phone: string
-}
+import { searchActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
const CONTACT_OPTION_LIMIT = 20
export const searchActionPlanContactOptions = async (
query: string,
-): Promise => {
+): Promise => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
@@ -44,3 +40,12 @@ export const searchActionPlanContactOptions = async (
return result.docs.map(({ id, name, phone }) => ({ id, name, phone }))
}
+
+export const searchActionPlanLeadershipOptionsAction = async (
+ query: string,
+): Promise> => {
+ const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
+ if (!user) throw new Error('Autenticação necessária.')
+
+ return searchActionPlanLeadershipOptions(payload, user, query)
+}
diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
index 49c9aa60..c726f33b 100644
--- a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx
@@ -2,10 +2,12 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
-import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
+import {
+ searchActionPlanContactOptions,
+ searchActionPlanLeadershipOptionsAction,
+} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/ActionPlanForm'
-import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions'
@@ -15,10 +17,7 @@ export default async function NewActionPlanPage() {
if (!user) redirect('/campanha/login')
if (user.role !== 'geral' && user.role !== 'coordenador') redirect('/campanha/planos')
- const [coordinators, leadershipOptions] = await Promise.all([
- getEligibleNucleusCoordinatorOptions(payload, user),
- getActionPlanLeadershipOptions(payload, user),
- ])
+ const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user)
return (
@@ -33,9 +32,9 @@ export default async function NewActionPlanPage() {
action={createActionPlanFormAction}
coordinators={coordinators}
canManageCoordinators={user.role === 'geral'}
- leadershipOptions={leadershipOptions}
submitLabel="Criar plano"
searchContacts={searchActionPlanContactOptions}
+ searchLeaderships={searchActionPlanLeadershipOptionsAction}
/>
)
diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts
index fe4e7535..88a02089 100644
--- a/src/app/(campaign)/campanha/actions/actionPlan.ts
+++ b/src/app/(campaign)/campanha/actions/actionPlan.ts
@@ -116,6 +116,7 @@ export const toggleActionPlanTaskRecord = async (
depth: 0,
user: currentActor,
overrideAccess: false,
+ context: { mutationKind: 'taskToggle' },
req,
})
},
@@ -159,6 +160,7 @@ export const appendActionPlanUpdateRecord = async (
depth: 0,
user: currentActor,
overrideAccess: false,
+ context: { mutationKind: 'appendUpdate' },
req,
})
},
diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts
index ef419fc4..93f13f07 100644
--- a/src/collections/ActionPlan.ts
+++ b/src/collections/ActionPlan.ts
@@ -34,6 +34,9 @@ import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
+const isActionPlanMutationShortcut = (context: Record | undefined) =>
+ context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate'
+
const ACTION_PLAN_KIND_OPTIONS = actionPlanKinds.map((value) => ({
value,
label: actionPlanKindLabels[value],
@@ -65,15 +68,22 @@ const actionPlanStaffFieldSnapshot = (doc: Record) => ({
leadership: relationshipId(doc.leadership),
})
-const validateActionPlanTerritory = createCampaignTerritoryValidationHook({
+const validateActionPlanTerritoryCore = createCampaignTerritoryValidationHook({
entityLabel: 'plano',
})
+const validateActionPlanTerritory: CollectionBeforeValidateHook = (args) => {
+ if (isActionPlanMutationShortcut(args.context)) return args.data
+ return validateActionPlanTerritoryCore(args)
+}
+
const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
+ context,
}) => {
+ if (isActionPlanMutationShortcut(context)) return data
if (!data) return data
const title = trimmedText(data.title ?? originalDoc?.title)
const slug = slugify(title)
@@ -92,7 +102,9 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
data,
operation,
originalDoc,
+ context,
}) => {
+ if (isActionPlanMutationShortcut(context)) return data
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
@@ -118,7 +130,8 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({
return data
}
-const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => {
+const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req, context }) => {
+ if (isActionPlanMutationShortcut(context)) return data
if (!data) return data
if (data.coordinators === undefined) return data
@@ -201,16 +214,18 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o
}
if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') {
- const previous = (originalDoc ?? {}) as Record
- const merged = { ...previous, ...(data as Record) }
- if (
- JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !==
- JSON.stringify(actionPlanStaffFieldSnapshot(merged))
- ) {
- throw new APIError(
- 'Lideranças só podem marcar tarefas e registrar atualizações no plano.',
- 403,
- )
+ if (!isActionPlanMutationShortcut(req.context)) {
+ const previous = (originalDoc ?? {}) as Record
+ const merged = { ...previous, ...(data as Record) }
+ if (
+ JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !==
+ JSON.stringify(actionPlanStaffFieldSnapshot(merged))
+ ) {
+ throw new APIError(
+ 'Lideranças só podem marcar tarefas e registrar atualizações no plano.',
+ 403,
+ )
+ }
}
if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx
index cd9a7bb4..a0125168 100644
--- a/src/components/campaign/ActionPlanForm.tsx
+++ b/src/components/campaign/ActionPlanForm.tsx
@@ -4,6 +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 { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
@@ -21,15 +22,16 @@ import {
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
-import { Spinner } from '@/components/ui/Spinner'
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'
import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime'
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
-import { fieldError } from '@/utilities/campaignFormFields'
export type ActionPlanFormState = {
message?: string
@@ -43,16 +45,14 @@ type ActionPlanFormAction = (
formData: FormData,
) => Promise
-export type { ActionPlanLeadershipOption }
-
export type ActionPlanFormFieldsProps = {
coordinators?: NucleusCoordinatorOption[]
canManageCoordinators?: boolean
- leadershipOptions?: ActionPlanLeadershipOption[]
plan?: ActionPlanFormViewModel
fieldErrors?: Record
submittedTitle?: string
searchContacts: (query: string) => Promise
+ searchLeaderships: (query: string) => Promise
}
const editableStatuses = ['rascunho', 'planejado'] as const
@@ -60,11 +60,11 @@ const editableStatuses = ['rascunho', 'planejado'] as const
export const ActionPlanFormFields = ({
coordinators = [],
canManageCoordinators = false,
- leadershipOptions = [],
plan,
fieldErrors = {},
submittedTitle,
searchContacts,
+ searchLeaderships,
}: ActionPlanFormFieldsProps) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const statusIsEditable =
@@ -79,6 +79,9 @@ 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>(
+ plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null,
+ )
return (
@@ -300,22 +303,21 @@ export const ActionPlanFormFields = ({
- Liderança vinculada
- Liderança vinculada
+
- Nenhuma
- {leadershipOptions.map((option) => (
-
- {option.label}
-
- ))}
-
+ 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."
+ />
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
atualizações.
@@ -389,10 +391,10 @@ export const ActionPlanForm = ({
action,
coordinators,
canManageCoordinators = false,
- leadershipOptions = [],
plan,
submitLabel,
searchContacts,
+ searchLeaderships,
}: ActionPlanFormFieldsProps & {
action: ActionPlanFormAction
submitLabel: string
@@ -416,11 +418,11 @@ export const ActionPlanForm = ({