Deliver C7 scale/DRY for action plans: territory composition, tab-scoped reads, and hook shortcuts.

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 03:50:58 -03:00
parent cc66ec3c31
commit 15c756bb10
18 changed files with 301 additions and 991 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):** 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).

View file

@ -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

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 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<br/>(import/listas/forms) ✓"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3<br/>(parcial: contadores/locks/FormData)"]
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras/hooks) ✓"]
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)"]
@ -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 |

View file

@ -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}
/>
</div>
)

View file

@ -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({
<p className="text-muted-foreground">Nenhum coordenador vinculado.</p>
)}
<p className="text-muted-foreground">
{view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas
concluídas
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>

View file

@ -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<ActionPlanContactOption[]> => {
): Promise<ContactComboboxOption[]> => {
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<Array<{ id: number; label: string }>> => {
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
return searchActionPlanLeadershipOptions(payload, user, query)
}

View file

@ -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 (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
@ -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}
/>
</div>
)

View file

@ -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,
})
},

View file

@ -34,6 +34,9 @@ import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const isActionPlanMutationShortcut = (context: Record<string, unknown> | 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<string, unknown>) => ({
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<string, unknown>
const merged = { ...previous, ...(data as Record<string, unknown>) }
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<string, unknown>
const merged = { ...previous, ...(data as Record<string, unknown>) }
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)) {

View file

@ -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<ActionPlanFormState>
export type { ActionPlanLeadershipOption }
export type ActionPlanFormFieldsProps = {
coordinators?: NucleusCoordinatorOption[]
canManageCoordinators?: boolean
leadershipOptions?: ActionPlanLeadershipOption[]
plan?: ActionPlanFormViewModel
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
searchLeaderships: (query: string) => Promise<ActionPlanLeadershipOption[]>
}
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 (
<FieldGroup>
@ -300,22 +303,21 @@ export const ActionPlanFormFields = ({
</Field>
<Field data-invalid={Boolean(errorFor('leadership'))}>
<FieldLabel htmlFor="leadership">Liderança vinculada</FieldLabel>
<NativeSelect
id="leadership"
<FieldLabel>Liderança vinculada</FieldLabel>
<AsyncSearchCombobox
name="leadership"
defaultValue={plan?.leadership?.id ? String(plan.leadership.id) : ''}
aria-invalid={Boolean(errorFor('leadership'))}
aria-describedby={errorFor('leadership') ? 'leadership-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Nenhuma</NativeSelectOption>
{leadershipOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
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."
/>
<FieldDescription>
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 = ({
<ActionPlanFormFields
coordinators={coordinators}
canManageCoordinators={canManageCoordinators}
leadershipOptions={leadershipOptions}
plan={plan}
fieldErrors={state.fieldErrors}
submittedTitle={state.submittedTitle}
searchContacts={searchContacts}
searchLeaderships={searchLeaderships}
/>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button asChild variant="outline" className="min-h-11">

View file

@ -1,18 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronsUpDownIcon } from 'lucide-react'
import { useRef } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
export type ContactComboboxOption = {
@ -30,8 +20,8 @@ type ContactComboboxProps = {
onChange?: (contact: ContactComboboxOption | null) => void
}
const contactLabel = (contact: ContactComboboxOption | null, emptyLabel: string): string =>
contact ? `${contact.name} · ${contact.phone}` : emptyLabel
const contactLabel = (contact: ContactComboboxOption): string =>
`${contact.name} · ${contact.phone}`
export const ContactCombobox = ({
name,
@ -41,132 +31,31 @@ export const ContactCombobox = ({
emptyOptionLabel = 'Nenhum responsável',
onChange,
}: ContactComboboxProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<ContactComboboxOption | null>(current)
const [options, setOptions] = useState<ContactComboboxOption[]>([])
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
useEffect(() => {
if (!open) return
if (!isContactSearchQueryReady(query)) {
setOptions([])
setLoading(false)
setFailed(false)
return
}
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void search(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, search])
const choose = (contact: ContactComboboxOption | null) => {
setSelected(contact)
setOpen(false)
setQuery('')
onChange?.(contact)
}
const visibleOptions = options.filter((option) => option.id !== selected?.id)
const contactsById = useRef(new Map<number, ContactComboboxOption>())
if (current) contactsById.current.set(current.id, current)
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}: ${contactLabel(selected, emptyOptionLabel)}`}
onClick={() => setOpen(true)}
>
<span className="truncate">{contactLabel(selected, emptyOptionLabel)}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={label}
description="Busque por nome ou celular."
showCloseButton
>
<Command shouldFilter={false} label="Buscar contato por nome ou celular">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar por nome ou celular"
aria-label="Buscar contato por nome ou celular"
/>
<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 buscar contatos.
</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">{contactLabel(selected, emptyOptionLabel)}</span>
</CommandItem>
</CommandGroup>
) : null}
{visibleOptions.length ? (
<CommandGroup heading="Contatos">
{visibleOptions.map((contact) => (
<CommandItem
key={contact.id}
value={`contact-${contact.id}`}
onSelect={() => choose(contact)}
>
<span className="truncate">{contactLabel(contact, emptyOptionLabel)}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{!isContactSearchQueryReady(query)
? 'Digite ao menos dois caracteres para buscar.'
: 'Nenhum contato encontrado.'}
</p>
)}
</>
)}
</CommandList>
</Command>
</CommandDialog>
</>
<AsyncSearchCombobox
name={name}
label={label}
value={
current ? { id: current.id, label: contactLabel(current) } : null
}
emptyOptionLabel={emptyOptionLabel}
dialogDescription="Busque por nome ou celular."
isQueryReady={isContactSearchQueryReady}
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
search={async (query) => {
const results = await search(query)
for (const contact of results) contactsById.current.set(contact.id, contact)
return results.map((contact) => ({
id: contact.id,
label: contactLabel(contact),
}))
}}
onChange={(option) =>
onChange?.(option ? (contactsById.current.get(option.id) ?? null) : null)
}
/>
)
}

View file

@ -81,6 +81,11 @@ export const LeadershipPrimaryContactAction = ({
router.refresh()
}, [router, state.message, state.status])
const displayState = state.status === 'success' ? {} : state
const nucleusError = fieldError(displayState.fieldErrors, 'nucleus')
const contactError = fieldError(displayState.fieldErrors, 'contact')
const hasError = Boolean(displayState.message || nucleusError || contactError)
return (
<form
action={action}
@ -89,7 +94,7 @@ export const LeadershipPrimaryContactAction = ({
<PrimaryContactFormFields
nucleusId={nucleusId}
contactId={contactId}
state={state.status === 'success' ? {} : state}
state={displayState}
/>
<Button type="submit" variant="outline" className="min-h-11 w-full" disabled={pending}>
{pending ? (

View file

@ -1,57 +1,23 @@
'use client'
import { useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { CampaignTerritoryCoreFields } from '@/components/campaign/CampaignTerritoryCoreFields'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { territoriesForCities } from '@/lib/bahiaTerritories'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
useCampaignTerritoryFieldsState,
type CampaignTerritoryValues,
} from '@/components/campaign/useCampaignTerritoryFieldsState'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import {
allMunicipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
import { fieldError } from '@/utilities/campaignFormFields'
type TerritoryAndZonesValues = {
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
type TerritoryAndZonesValues = CampaignTerritoryValues & {
tseZones?: number[] | null
}
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 NucleusTerritoryAndZonesFields = ({
values,
fieldErrors = {},
@ -59,260 +25,29 @@ export const NucleusTerritoryAndZonesFields = ({
values?: TerritoryAndZonesValues
fieldErrors?: Record<string, string[]>
}) => {
const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
const [neighborhoods, setNeighborhoods] = useState(() =>
dedupeTrimmedStrings(values?.neighborhoods ?? []),
)
const state = useCampaignTerritoryFieldsState(values)
const [tseZones, setTseZones] = useState(() => sortedUniqueZoneNumbers(values?.tseZones ?? []))
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 errorFor = (name: string) => fieldError(fieldErrors, name)
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
const displayRegions = cities.length > 0 ? derivedRegions : regions
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const { zoneSuggestions, citySuggestions, outsideZones } = useMemo(
() =>
buildTerritorySuggestions({
cities,
regions: displayRegions,
cities: state.cities,
regions: state.displayRegions,
tseZones,
}),
[cities, displayRegions, tseZones],
[state.cities, state.displayRegions, tseZones],
)
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
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)
}
return (
<div className="grid gap-4 sm:grid-cols-2">
{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="nucleus-lookup-a">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="nucleus-lookup-a"
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="nucleus-lookup-a-error">{visibleRegionError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor="nucleus-lookup-b">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="nucleus-lookup-b"
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="nucleus-lookup-b-error">{visibleCityError}</FieldError>
) : null}
</Field>
<Field
data-disabled={!neighborhoodsEnabled}
data-invalid={Boolean(visibleNeighborhoodError)}
className="sm:col-span-2"
>
<FieldLabel htmlFor="neighborhoodDraft">Bairros</FieldLabel>
{neighborhoodsEnabled && neighborhoods.length ? (
<ChipList
values={neighborhoods}
onRemove={(neighborhood) =>
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
}
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
/>
) : null}
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
<InputGroupInput
id="neighborhoodDraft"
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 ? 'neighborhoods-error' : '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
) {
setNeighborhoods((current) => current.slice(0, -1))
}
}}
onBlur={() => commitNeighborhood(neighborhoodDraft)}
/>
</InputGroup>
<FieldDescription id="neighborhoods-description">
Use Enter para adicionar. Disponível somente quando há exatamente um município.
</FieldDescription>
{visibleNeighborhoodError ? (
<FieldError id="neighborhoods-error">{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
<CampaignTerritoryCoreFields
state={state}
idPrefix="nucleus"
fieldErrors={fieldErrors}
citySuggestions={citySuggestions}
/>
<Field data-invalid={Boolean(errorFor('tseZones'))} className="sm:col-span-2">
<FieldLabel htmlFor="tseZoneDraft">Zonas TSE</FieldLabel>

View file

@ -1,53 +1,10 @@
'use client'
import { useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { territoriesForCities } from '@/lib/bahiaTerritories'
import { CampaignTerritoryCoreFields } from '@/components/campaign/CampaignTerritoryCoreFields'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import {
allMunicipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
import { fieldError } from '@/utilities/campaignFormFields'
type TerritoryValues = {
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
}
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>
)
useCampaignTerritoryFieldsState,
type CampaignTerritoryValues,
} from '@/components/campaign/useCampaignTerritoryFieldsState'
/**
* Territory-only fields (regions/cities/neighborhoods) shared by any campaign
@ -59,262 +16,14 @@ export const CampaignTerritoryFields = ({
values,
fieldErrors = {},
}: {
values?: TerritoryValues
values?: CampaignTerritoryValues
fieldErrors?: Record<string, string[]>
}) => {
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 errorFor = (name: string) => fieldError(fieldErrors, name)
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
const displayRegions = cities.length > 0 ? derivedRegions : regions
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const { citySuggestions } = useMemo(
() =>
buildTerritorySuggestions({
cities,
regions: displayRegions,
tseZones: [],
}),
[cities, displayRegions],
)
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
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 state = useCampaignTerritoryFieldsState(values)
return (
<div className="grid gap-4 sm:grid-cols-2">
{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="plan-lookup-a">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="plan-lookup-a"
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="plan-lookup-a-error">{visibleRegionError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor="plan-lookup-b">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="plan-lookup-b"
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="plan-lookup-b-error">{visibleCityError}</FieldError>
) : null}
</Field>
<Field
data-disabled={!neighborhoodsEnabled}
data-invalid={Boolean(visibleNeighborhoodError)}
className="sm:col-span-2"
>
<FieldLabel htmlFor="plan-neighborhoodDraft">Bairros</FieldLabel>
{neighborhoodsEnabled && neighborhoods.length ? (
<ChipList
values={neighborhoods}
onRemove={(neighborhood) =>
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
}
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
/>
) : null}
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
<InputGroupInput
id="plan-neighborhoodDraft"
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 ? 'plan-neighborhoods-error' : 'plan-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
) {
setNeighborhoods((current) => current.slice(0, -1))
}
}}
onBlur={() => commitNeighborhood(neighborhoodDraft)}
/>
</InputGroup>
<FieldDescription id="plan-neighborhoods-description">
Use Enter para adicionar. Disponível somente quando há exatamente um município.
</FieldDescription>
{visibleNeighborhoodError ? (
<FieldError id="plan-neighborhoods-error">{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
<CampaignTerritoryCoreFields state={state} idPrefix="plan" fieldErrors={fieldErrors} />
</div>
)
}

View file

@ -1,18 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronsUpDownIcon } from 'lucide-react'
import { useRef } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
import type {
NucleusPrimaryContactPageData,
PrimaryContactOption,
@ -24,136 +14,36 @@ type PrimaryContactComboboxProps = {
search: (query: string) => Promise<NucleusPrimaryContactPageData>
}
const contactLabel = (contact: PrimaryContactOption | null) =>
contact ? `${contact.name} · ${contact.phone}` : 'Sem contato principal'
const contactLabel = (contact: PrimaryContactOption) =>
`${contact.name} · ${contact.phone}`
export const PrimaryContactCombobox = ({
name,
current,
search,
}: PrimaryContactComboboxProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selected, setSelected] = useState<PrimaryContactOption | null>(current)
const [result, setResult] = useState<NucleusPrimaryContactPageData>({
current,
options: [],
})
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const requestId = useRef(0)
useEffect(() => {
if (!open) return
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void search(query)
.then((nextResult) => {
if (requestId.current !== currentRequestId) return
setResult({
current: nextResult.current,
options: nextResult.options.slice(0, nextResult.current ? 99 : 100),
})
})
.catch(() => {
if (requestId.current !== currentRequestId) return
setFailed(true)
})
.finally(() => {
if (requestId.current === currentRequestId) setLoading(false)
})
}, 250)
return () => window.clearTimeout(timeout)
}, [open, query, search])
const choose = (contact: PrimaryContactOption | null) => {
setSelected(contact)
setOpen(false)
setQuery('')
}
const contactsById = useRef(new Map<number, PrimaryContactOption>())
if (current) contactsById.current.set(current.id, current)
return (
<>
<input type="hidden" name={name} value={selected?.id ?? ''} />
<Button
type="button"
variant="outline"
className="min-h-11 w-full justify-between font-normal"
aria-label={`Contato principal: ${selected?.name ?? 'nenhum'}`}
onClick={() => setOpen(true)}
>
<span className="truncate">{contactLabel(selected)}</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Selecionar contato principal"
description="Busque entre as lideranças engajadas deste núcleo."
showCloseButton
>
<Command shouldFilter={false} label="Buscar liderança por nome ou celular">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar por nome ou celular"
aria-label="Buscar liderança por nome ou celular"
/>
<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 buscar as lideranças.
</p>
) : (
<>
<CommandGroup heading="Seleção">
<CommandItem value="none" onSelect={() => choose(null)}>
Sem contato principal
</CommandItem>
</CommandGroup>
{result.current ? (
<CommandGroup heading="Contato atual">
<CommandItem
value={`current-${result.current.id}`}
data-checked={selected?.id === result.current.id}
onSelect={() => choose(result.current)}
>
<span className="truncate">{contactLabel(result.current)}</span>
</CommandItem>
</CommandGroup>
) : null}
{result.options.length ? (
<CommandGroup heading="Lideranças engajadas">
{result.options.map((contact) => (
<CommandItem
key={contact.id}
value={`contact-${contact.id}`}
data-checked={selected?.id === contact.id}
onSelect={() => choose(contact)}
>
<span className="truncate">{contactLabel(contact)}</span>
</CommandItem>
))}
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Nenhuma liderança encontrada.
</p>
)}
</>
)}
</CommandList>
</Command>
</CommandDialog>
</>
<AsyncSearchCombobox
name={name}
label="Selecionar contato principal"
value={current ? { id: current.id, label: contactLabel(current) } : null}
emptyOptionLabel="Sem contato principal"
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,
label: contactLabel(contact),
}))
}}
pinnedOptions={current ? [{ id: current.id, label: contactLabel(current) }] : []}
pinnedGroupHeading="Contato atual"
/>
)
}

View file

@ -2,6 +2,10 @@ import 'server-only'
import type { Payload } from 'payload'
import {
isContactSearchQueryReady,
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import type { CampaignUser, Contact, Leadership } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
@ -10,19 +14,44 @@ export type ActionPlanLeadershipOption = {
label: string
}
const LEADERSHIP_SEARCH_LIMIT = 20
const leadershipLabel = (leadership: Leadership): string | null => {
const id = relationshipId(leadership.id)
if (!id) return null
const contact = leadership.contact
return isPopulatedRelationship<Contact>(contact) ? contact.name : `Liderança #${id}`
}
/**
* Engaged leaderships visible to the actor (for linking a plan to a leadership).
* Uses overrideAccess: false so Payload row-level access applies.
*/
export const getActionPlanLeadershipOptions = async (
export const searchActionPlanLeadershipOptions = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
query: string,
): Promise<ActionPlanLeadershipOption[]> => {
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery)
const result = await payload.find({
collection: 'leadership',
where: { supportStatus: { equals: 'engajado' } },
where: {
and: [
{ supportStatus: { equals: 'engajado' } },
{
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
],
},
],
},
depth: 1,
limit: 200,
limit: LEADERSHIP_SEARCH_LIMIT,
sort: 'id',
select: {
contact: true,
@ -36,11 +65,8 @@ export const getActionPlanLeadershipOptions = async (
.map((doc) => {
const leadership = doc as Leadership
const id = relationshipId(leadership.id)
if (!id) return null
const contact = leadership.contact
const label = isPopulatedRelationship<Contact>(contact)
? contact.name
: `Liderança #${id}`
const label = leadershipLabel(leadership)
if (!id || !label) return null
return { id, label }
})
.filter((option): option is ActionPlanLeadershipOption => option !== null)

View file

@ -1,11 +1,11 @@
import type { Payload } from 'payload'
import type { ActionPlan, CampaignUser } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import {
actionPlanDetailSelect,
actionPlanFormSelect,
actionPlanListSelect,
toActionPlanDetailViewModel,
getActionPlanDetailSelect,
toActionPlanFormViewModel,
} from '@/utilities/actionPlanViewModels'
import {
@ -52,16 +52,20 @@ export const loadActionPlanListPageData = async (
return { result, state }
}
const getActionPlanDetailQueryDepth = (activeTab: ActionPlanDetailTab): number =>
activeTab === 'updates' ? 0 : 1
const loadAccessibleActionPlanBySlug = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
planSlug: string,
select: typeof actionPlanDetailSelect | typeof actionPlanFormSelect,
select: ReturnType<typeof getActionPlanDetailSelect> | typeof actionPlanFormSelect,
depth: number,
): Promise<ActionPlan> => {
const result = await payload.find({
collection: 'actionPlan',
where: { slug: { equals: planSlug } },
depth: 1,
depth,
limit: 1,
pagination: false,
select,
@ -77,12 +81,14 @@ export const resolveAccessibleActionPlanContext = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
planSlug: string,
activeTab: ActionPlanDetailTab = 'overview',
): Promise<AccessibleActionPlanContext> => {
const document = await loadAccessibleActionPlanBySlug(
payload,
user,
planSlug,
actionPlanDetailSelect,
getActionPlanDetailSelect(activeTab),
getActionPlanDetailQueryDepth(activeTab),
)
return {
@ -92,14 +98,11 @@ export const resolveAccessibleActionPlanContext = async (
}
}
export const getActionPlanDetailPageData = (context: AccessibleActionPlanContext) =>
toActionPlanDetailViewModel(context.document)
export const getActionPlanEditPageData = async (
payload: Payload,
user: CampaignUser,
planSlug: string,
) =>
toActionPlanFormViewModel(
await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect),
await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect, 1),
)

View file

@ -1,4 +1,5 @@
import type { ActionPlan, CampaignUser, Contact } from '@/payload-types'
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
const asStringArray = (value: string[] | null | undefined): string[] =>
@ -175,7 +176,7 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM
})),
})
export const actionPlanDetailSelect = {
export const actionPlanDetailContextSelect = {
title: true,
slug: true,
kind: true,
@ -191,13 +192,31 @@ export const actionPlanDetailSelect = {
territoryNotes: true,
coordinators: true,
responsible: true,
tasks: true,
updates: true,
createdBy: true,
updatedAt: true,
createdAt: true,
} as const
export const getActionPlanDetailSelect = (activeTab: ActionPlanDetailTab) => {
if (activeTab === 'overview') {
return {
...actionPlanDetailContextSelect,
taskDoneCount: true,
taskTotal: true,
} as const
}
if (activeTab === 'tasks') {
return {
...actionPlanDetailContextSelect,
tasks: true,
} as const
}
return {
...actionPlanDetailContextSelect,
updates: true,
} as const
}
export type ActionPlanTaskViewModel = {
id: string | null
title: string
@ -234,6 +253,7 @@ export type ActionPlanDetailViewModel = {
coordinators: Array<{ id: number; name: string }>
responsibleId: number | null
responsibleName: string | null
taskProgress: { done: number; total: number }
tasks: ActionPlanTaskViewModel[]
updates: ActionPlanUpdateViewModel[]
createdByName: string | null
@ -241,7 +261,38 @@ export type ActionPlanDetailViewModel = {
createdAt: string
}
export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailViewModel => ({
const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] =>
(plan.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsibleId: relationshipId(task.responsible),
responsibleName: relationshipName(task.responsible),
due: task.due ?? null,
done: Boolean(task.done),
doneAt: task.doneAt ?? null,
}))
export const mapActionPlanUpdates = (
plan: ActionPlan,
authorNamesById: ReadonlyMap<number, string> = new Map(),
): ActionPlanUpdateViewModel[] =>
(plan.updates ?? []).map((update) => {
const authorId = relationshipId(update.author)
const populatedName = relationshipName(update.author)
return {
id: update.id ?? null,
body: update.body,
authorName:
populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null),
createdAt: update.createdAt ?? null,
}
})
export const toActionPlanDetailViewModel = (
plan: ActionPlan,
activeTab: ActionPlanDetailTab = 'overview',
authorNamesById: ReadonlyMap<number, string> = new Map(),
): ActionPlanDetailViewModel => ({
id: plan.id,
title: plan.title,
slug: plan.slug,
@ -265,21 +316,13 @@ export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailV
.map(({ id, name }) => ({ id, name })) ?? [],
responsibleId: relationshipId(plan.responsible),
responsibleName: relationshipName(plan.responsible),
tasks: (plan.tasks ?? []).map((task) => ({
id: task.id ?? null,
title: task.title,
responsibleId: relationshipId(task.responsible),
responsibleName: relationshipName(task.responsible),
due: task.due ?? null,
done: Boolean(task.done),
doneAt: task.doneAt ?? null,
})),
updates: (plan.updates ?? []).map((update) => ({
id: update.id ?? null,
body: update.body,
authorName: relationshipName(update.author),
createdAt: update.createdAt ?? null,
})),
taskProgress: {
done: plan.taskDoneCount ?? 0,
total: plan.taskTotal ?? 0,
},
tasks: activeTab === 'tasks' ? mapActionPlanTasks(plan) : [],
updates:
activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [],
createdByName: relationshipName(plan.createdBy),
updatedAt: plan.updatedAt,
createdAt: plan.createdAt,