Deliver E1 vote goals and E3 strategy fields on nuclei.

Wire schema, UI, aggregates, and tests for scenario goals, priority filter, staff-only dobradinha notes and next steps; register E6 scale follow-ups from simplify.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 04:36:18 -03:00
parent 456b5d8a4e
commit e5e051d175
31 changed files with 835 additions and 117 deletions

View file

@ -0,0 +1,114 @@
# Escala e DRY pós-E1+E3 (metas, prioridade, estratégia manual)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E6)
Responsável: —
## Contexto
O E1+E3 ([mapa-projecao-municipios.md](mapa-projecao-municipios.md)) entrega `voteGoals` (`good`/`regular`/`minimum`), `priority` (`alta|normal`), `dobradinhaNotes` e `nextSteps` em `electoralNucleus` (migration `20260719_054522_add_nucleus_goals_strategy`), com UI no detalhe (`NucleusVoteGoals`), formulário, inteligência, filtro `?priority=alta`, somas no overview B1 e KPI "Meta regular (soma)" no dashboard geral. Domínio centralizado em `src/utilities/voteGoals.ts` (`getVoteGoalsOrderViolation`, `aggregateVoteGoals`, `sumVoteGoals`, `voteGoalProgressPercent`); select compartilhado `nucleusVoteGoalsSelect` em `nucleusViewModels.ts`.
Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: select fragment único, remoção de `voteGoals` do `nucleusListSelect` (lista só usa `priority`), remoção de `priority` do select do dashboard geral, `aggregateVoteGoals` em um passe no overview VM, labels E3 em `nucleusUi.ts` (client-safe), `step={1}` nos inputs de meta, testes esparsos de ordem.
Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Sem registro, E2/E4 e B3 (coroplético com metas) herdam o mesmo custo de I/O na lista de núcleos.
1. **Query duplicada na página `/campanha/nucleos`.** `page.tsx` chama em paralelo `loadNucleusListPageData` (paginado, `depth: 1`) e `loadNucleusListOverviewData` (`pagination: false`, conjunto filtrado inteiro). Overview precisa do agregado; a lista precisa da página — mas hoje são **dois round-trips** sobre linhas sobrepostas. Em filtros amplos, o fetch unpaginado do overview domina.
2. **Geografia resolvida duas vezes para baseline no overview.** `nucleusListOverviewPageData.ts` mapeia com `toNucleusElectionGeographyInput`; `loadNucleusBaseline2022Overview` chama `resolveNucleusElectionGeography` de novo por núcleo (mais pesado: `tseZonesForCity`, interseções).
3. **Agregados de metas só em memória.** `buildNucleusListOverviewViewModel` e `buildGeneralDashboardViewModel` carregam todos os núcleos do escopo e somam com `aggregateVoteGoals` / `sumVoteGoals`. O precedente C6 (`supporterListOverviewAggregate.ts`, `COUNT(*) FILTER`) ainda não foi aplicado a metas/prioridade.
4. **DRY de formatação e KPIs.** `formatElectionNumber` já existe em `electionInsights.ts` e é usado em `NucleusVoteGoals`, mas `NucleusCard`, `NucleusList`, `NucleusListOverview` e `CampaignDashboard` ainda instanciam `Intl.NumberFormat('pt-BR')` local. `percentage(part, total)` está duplicado em `nucleusListOverviewViewModels.ts` e `campaignDashboardViewModels.ts`. Rótulos de cenário (Bom/Regular/Mínimo) estão hardcoded em quatro superfícies.
5. **`DashboardNucleusRecord` enganoso para não-`geral`.** `toDashboardNucleusRecord` sempre preenche `voteGoals`/`priority`, mas selects de coordenador/liderança omitem esses campos → nulls/`'normal'` sintéticos.
**Explicitamente fora (revisores pediram skip no simplify):** `NucleusPriorityBadge` como componente (só 3 call sites), merge Zod parcial de `voteGoals` no patch (Payload `beforeValidate` já faz merge), mover `voteGoals.ts` para `src/lib/` por layering, tipos `VoteGoalsFields` vs `VoteGoalsViewModel` (cerimônia aceitável), bug `hasError` em `LeadershipPrimaryContactAction.tsx` (pré-existente em `main`, C9).
## Objetivos
- Abrir `/campanha/nucleos` com filtro amplo não escala com **duas** queries full-scan equivalentes; agregados de metas/prioridade preferem **uma** fonte de verdade (loader compartilhado ou SQL aggregate).
- Card "Baseline 2022" no overview não re-resolve geografia que o mapper de núcleos já calculou.
- Formatação de votos/metas e percentuais de KPI reutilizam helpers únicos (`formatElectionNumber`, `campaignPercentage`, `voteGoalScenarioLabels`).
- Guardrails: Fases 1–2 podem ser sem migration (loader/SQL read-only); sem Consent (metas não são PII). Access continua `overrideAccess: false` + selects/VMs por papel. Valores enum `alta|normal` permanecem dados em português (AGENTS.md).
## Decisões travadas
- **Um item E6, quatro fases ordenadas.** Mesmo racional de A7/C8/C10: um ID no roadmap, PRs por fase. Ordem: loader/aggregate (custo real) → cache de geografia baseline → DRY UI/helpers → tipos do dashboard por papel (qualidade).
- **Dependência dura de E1+E3 mergeados em `main`.** Só faz sentido com campos e UI já no produto; não reabre escopo de E2/E4.
- **Fase 1 não remove o painel overview nem o KPI do dashboard.** Otimiza como os dados chegam; paridade com a planilha permanece.
- **Cortável se o número de núcleos ativos permanecer pequeno** (dezenas). Vira não-cortável quando B3 coroplético ou E4 import multiplicarem o conjunto filtrado.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadNucleusListPageBundle`, `nucleusListOverviewAggregate`, `voteGoalScenarioLabels`, `formatNullableElectionNumber`); strings visíveis e valores enum `alta|normal` em pt-BR.
## Questões em aberto
- **Fase 1: unificar loaders vs SQL aggregate puro?** **Recomendação:** espelhar C6 — `nucleusListOverviewAggregate.ts` com `SUM`/`COUNT(*) FILTER` espelhando `buildNucleusListWhere` + access; lista paginada mantém query própria; overview consome só aggregate + preview de updates (não re-lê todos os núcleos para somar metas). Unificar lista+overview num único `find` unpaginado só se a paginação puder ser in-memory sem regressão de `depth`/coordinadores — validar com teste int.
- **Fase 2: passar geografia resolvida ou memoizar no contexto da request?** **Recomendação:** estender `NucleusListOverviewNucleusRecord` com o objeto já resolvido (ou mapa `id → NucleusElectionGeography`) e fazer `loadNucleusBaseline2022Overview` aceitar entrada pré-resolvida; fallback para resolve atual em outros call sites.
- **Fase 4: tipos separados por role no dashboard?** **Recomendação:** `DashboardGeralNucleusRecord` com `voteGoals` + `DashboardScopedNucleusRecord` sem — `buildScopedDashboardViewModel` deixa de receber campos fantasma.
## Abordagem proposta
```mermaid
flowchart TD
E1["E1+E3 Metas/prioridade/E3 ✓"] --> F1
F1["Fase 1 — Aggregate SQL ou loader único<br/>(metas + prioritários + KPIs)"]
F1 --> F2["Fase 2 — Geografia baseline sem re-resolve"]
F1 --> F3["Fase 3 — formatElectionNumber + percentage + scenario labels"]
F1 --> F4["Fase 4 — Dashboard records por role"]
F1 -.menos I/O.-> E4["E4 Import planilha"]
F1 -.menos I/O.-> B3["B3 Coroplético com metas"]
F2 -.mesmo path.-> A7["A7 Baseline loader"]
```
### Fase 1 — Aggregate / loader da lista de núcleos
- Novo `src/utilities/nucleusListOverviewAggregate.ts` (ou extensão de `nucleusListOverviewPageData.ts`): uma query drizzle/SQL com `SUM(vote_goals_regular)` etc. e `COUNT(*) FILTER (WHERE priority = 'alta')`, espelhando `buildNucleusListWhere` + constraint de access do usuário (mesmo padrão de `supporterListOverviewAggregate.ts`).
- Dashboard geral: reutilizar o mesmo aggregate para `regularVoteGoalTotal` em vez de `sumVoteGoals` sobre todos os docs — ou query dedicada `status = ativo` se o escopo for global.
- Opcional na mesma fase: fundir `loadNucleusListOverviewData` com parte do list loader se o teste de perf mostrar ganho claro; caso contrário, manter duas queries mas overview **não** materializa array completo só para somar metas.
- Testes: unit do SQL/where mirror; int com filtros `priority=alta` + múltiplos núcleos assertando totais.
### Fase 2 — Cache de geografia no overview → baseline
- Em `nucleusListOverviewPageData.ts`, persistir geografia resolvida no record passado a `loadNucleusBaseline2022Overview`.
- Ajustar `nucleusElectoralBaseline.ts` para aceitar geografia pré-computada (overload ou tipo `PreresolvedNucleusGeography`).
- Critério: uma passagem `resolveNucleusElectionGeography` por núcleo no path do overview.
### Fase 3 — DRY de formatação e rótulos
- `src/lib/electionInsights.ts`: `formatNullableElectionNumber(value, empty = '—')`.
- Substituir `Intl.NumberFormat` local em `NucleusCard`, `NucleusList`, `NucleusListOverview`, `CampaignDashboard`.
- `src/utilities/voteGoals.ts`: `voteGoalScenarioLabels` (`good`/`regular`/`minimum` → Bom/Regular/Mínimo); usar em `NucleusForm`, `NucleusVoteGoals`, `NucleusListOverview`.
- Extrair `campaignPercentage` (ex. `src/utilities/campaignMath.ts`) usado por overview e dashboard.
- Opcional: `NucleusPriorityBadge` se os três call sites incomodarem no próximo touch.
### Fase 4 — Dashboard nucleus records por papel
- Separar tipos/loader: geral carrega `voteGoals`; coordenador/liderança não.
- `toDashboardNucleusRecord` vira duas funções ou genérico com select tipado.
**Migration:** nenhuma nas quatro fases (só leitura/agregação e tipos).
## Dependências
- **Dura:** E1 Metas + prioridade e E3 campos manuais — merge em `main` com migration `20260719_054522`.
- **Suave:** E4 import planilha e B3 coroplético — amplificam custo se Fase 1 atrasar; A7 compartilha loader de baseline com Fase 2.
- Reusa: `voteGoals.ts`, `nucleusVoteGoalsSelect`, `buildNucleusListWhere`, `supporterListOverviewAggregate.ts` (padrão), `formatElectionNumber`, `loadNucleusBaseline2022Overview`.
## Não escopo
- E2 série 2014/2018 → permanece no [mapa-projecao-municipios.md](mapa-projecao-municipios.md).
- E4 seed da planilha → idem.
- Dobradinha estruturada A6 / import nominal de pessoas da planilha.
- Renomear enum `alta` → `high` (migration + backfill).
- Zod merge de `voteGoals` parcial no patch.
- `LeadershipPrimaryContactAction` `hasError` (C9).
## Referências
- `docs/roadmap.md` (Trilha E, E6; E1–E5)
- `docs/plans/mapa-projecao-municipios.md` — plano pai E1–E5
- `docs/plans/escala-dry-pos-a4.md` / `escala-dry-pos-c6.md` — precedente pós-`/simplify`
- `src/app/(campaign)/campanha/(app)/nucleos/page.tsx` — parallel loaders
- `src/utilities/nucleusPageData.ts` / `nucleusListOverviewPageData.ts`
- `src/utilities/nucleusListOverviewViewModels.ts` / `campaignDashboardViewModels.ts`
- `src/utilities/voteGoals.ts` / `nucleusViewModels.ts`
- `src/utilities/nucleusElectoralBaseline.ts` — `resolveNucleusElectionGeography`, `loadNucleusBaseline2022Overview`
- `src/utilities/supporterListOverviewAggregate.ts` — padrão SQL aggregate
- `src/lib/electionInsights.ts` — `formatElectionNumber`
- AGENTS.md — naming, `overrideAccess: false`, enum values em português como dado

View file

@ -1,7 +1,7 @@
# Mapa de projeção de votos por município (equiparar e superar as planilhas 2026)
Status: rascunho
Atualizado em: 2026-07-19
Atualizado em: 2026-07-19 (revisão access E1/E3 — metas/prioridade/E3 sem `canSetDerivedNucleusField`; escrita via `canUpdateElectoralNucleus` + selects/VMs)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E — itens E1–E5, "Ciclo 3")
Responsável: —
@ -56,7 +56,7 @@ flowchart LR
### E1 — Metas em cenários + prioridade no Núcleo (migration)
- **`electoralNucleus`** (`src/collections/ElectoralNucleus.ts`): grupo `voteGoals` (`good`/`regular`/`minimum`, numbers opcionais, validação `good ≥ regular ≥ minimum`) e `priority` (select `alta|normal`, default `normal`, indexado). Escrita staff — `geral` ou coordenador designado, mesma regra da estimativa confirmada (`canSetDerivedNucleusField`/padrões em `src/utilities/campaignAccess.ts`); leitura para os três papéis.
- **`electoralNucleus`** (`src/collections/ElectoralNucleus.ts`): grupo `voteGoals` (`good`/`regular`/`minimum`, numbers opcionais, validação `good ≥ regular ≥ minimum`) e `priority` (select `alta|normal`, default `normal`, indexado). Escrita staff via `canUpdateElectoralNucleus` (`geral` ou coordenador designado no núcleo) — **sem** `canSetDerivedNucleusField` (esse helper é só Payload-admin, usado nas estimativas derivadas). Leitura para os três papéis; E3 (`dobradinhaNotes`/`nextSteps`) staff-only via select/VM como `strengths`/`risks`.
- **UI:** card "Metas 2026" na aba Visão geral (3 cenários + `Progress` da estimativa confirmada vs. meta regular, ao lado de `NucleusElectoralBaseline`); filtro `priority` na lista (padrão dos filtros existentes em `src/utilities/nucleusUi.ts` / `buildNucleusListWhere`); bloco no overview B1 (`src/components/campaign/NucleusListOverview.tsx`): soma das metas por cenário no conjunto filtrado + contagem de prioritários; dashboard: soma estadual das metas (equivalente à "META: 71.000").
- **Migration:** `pnpm migrate:create add_nucleus_goals_strategy` (uma migration só para E1+E3), `pnpm generate:types` (skill `payload-migrations`).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.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); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.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.
@ -87,7 +87,8 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
Objetivo: equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`) dentro do produto — metas em cenários, prioridade, série histórica com tendência, dobradinhas/encaminhamentos manuais e import único da planilha. Decisões de produto (2026-07-19): **sem entidade nova por município** (`electoralNucleus` absorve a camada estratégica) e **série 2014/2018 vem do TSE oficial** (estende o seed A3), não dos números da planilha. [Plano único](plans/mapa-projecao-municipios.md) para os cinco itens:
- **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada).
- **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada). Passagem `/simplify` no branch; débitos maiores → E6.
- **E6 Escala e DRY pós-E1+E3 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: query duplicada lista+overview, re-resolve de geografia no baseline do overview, aggregate SQL de metas/prioridade (padrão C6), DRY `formatElectionNumber`/`campaignPercentage`/`voteGoalScenarioLabels`, tipos do dashboard por papel. [Plano](plans/escala-dry-pos-e1.md).
- **E2 Série histórica TSE 2014/2018 + tendência** — seed `db:seed:tse` parametrizado por ano (só dep. federal, turno 1, BA); `computeVoteTrend` → `queda|mantem|aumento|semBaseline` derivado em leitura (sem migration); série + badge no baseline card e distribuição no overview.
- **E3 Dobradinhas + encaminhamentos manuais** — `dobradinhaNotes` e `nextSteps` staff-only na inteligência do núcleo (excluídos dos view models de `lideranca`); versão manual imediata que A6/C4 complementam depois.
- **E4 Import único da planilha** — `pnpm db:seed:mapa` idempotente por slug, município canônico via `canonicalizeMunicipalityName`; lideranças/assessores da planilha entram como **texto staff-only, nunca `Contact`/`leadership`** (sem telefone/consent).
@ -117,6 +118,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
| C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) |
@ -178,6 +180,7 @@ flowchart TD
E3["E3 Dobradinhas + encaminhamentos<br/>manuais (staff-only)"]
E4["E4 Import único da planilha<br/>(pnpm db:seed:mapa)"]
E5["E5 Salvador por bairro<br/>(futuro; validar valor)"]
E6["E6 Escala e DRY pós-E1+E3<br/>(aggregate / geo / DRY UI)"]
end
A1 --> A2
@ -215,6 +218,8 @@ flowchart TD
JUR -.chave de push.-> D2
DEPLOY -.engenharia entregue.-> O0DRY
A4 --> E2
E1 --> E6
E6 -.perf lista/overview.-> E4
E1 --> E4
E3 --> E4
E2 -.tendência no import.-> E4
@ -224,7 +229,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 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (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 ✓), **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 reduz custo da lista antes do import em massa).
### Sequência de execução por janela do calendário
@ -252,14 +257,15 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
| 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, C11 |
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10, C11 |
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`; `/simplify` → E6) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
| 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 merge em `main` | E2, C10, A7 |
| 18 | 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, E6, C7, C8, C9, C10, C11 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- |
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, B3, B5, C4, D2 |
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 |
| 20 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
| 21 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
@ -286,7 +292,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -44,7 +44,7 @@ export const updateElectoralNucleus = async (
return payload.update({
collection: 'electoralNucleus',
id,
data,
data: data as never,
depth: 0,
user: currentActor,
overrideAccess: false,

View file

@ -23,9 +23,62 @@ import { eligibleNucleusCoordinatorWhere } from '@/utilities/nucleusCoordinatorO
import { acquirePrimaryContactInvariantLocks } from '@/utilities/primaryContactInvariantLock'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/utilities/slug'
import {
getVoteGoalsOrderViolation,
VOTE_GOALS_ORDER_ERROR_MESSAGE,
type VoteGoalsFields,
} from '@/utilities/voteGoals'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const voteGoalNumber = (value: unknown): number | null | undefined => {
if (value === null || value === undefined || value === '') return null
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new APIError('Cada meta de votos deve ser um número inteiro não negativo.', 400)
}
return Math.trunc(value)
}
const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => {
if (!data) return data
const mergedGoals =
data.voteGoals !== undefined
? { ...(originalDoc?.voteGoals ?? {}), ...(data.voteGoals as object) }
: operation === 'update'
? originalDoc?.voteGoals
: undefined
if (!mergedGoals || typeof mergedGoals !== 'object') return data
const good = voteGoalNumber((mergedGoals as VoteGoalsFields).good)
const regular = voteGoalNumber((mergedGoals as VoteGoalsFields).regular)
const minimum = voteGoalNumber((mergedGoals as VoteGoalsFields).minimum)
if (getVoteGoalsOrderViolation({ good, regular, minimum })) {
throw new APIError(VOTE_GOALS_ORDER_ERROR_MESSAGE, 400)
}
if (data.voteGoals && typeof data.voteGoals === 'object') {
data.voteGoals = {
good:
(data.voteGoals as { good?: unknown }).good === undefined
? (originalDoc?.voteGoals?.good ?? null)
: good,
regular:
(data.voteGoals as { regular?: unknown }).regular === undefined
? (originalDoc?.voteGoals?.regular ?? null)
: regular,
minimum:
(data.voteGoals as { minimum?: unknown }).minimum === undefined
? (originalDoc?.voteGoals?.minimum ?? null)
: minimum,
}
}
return data
}
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = (args) => {
const validateTerritory = createCampaignTerritoryValidationHook({ entityLabel: 'núcleo' })
const data = validateTerritory(args)
@ -113,6 +166,7 @@ export const ElectoralNucleus: CollectionConfig = {
setCanonicalNucleusSlug,
validateNucleusTerritoryAndZones,
validateNucleusCoordinators,
validateVoteGoals,
],
beforeChange: [
({ data, operation, req }) => {
@ -415,6 +469,61 @@ export const ElectoralNucleus: CollectionConfig = {
},
],
},
{
name: 'dobradinhaNotes',
type: 'textarea',
label: 'Dobradinhas (notas)',
maxLength: 4000,
admin: {
description:
'Quem dobra no território hoje e o estado da negociação. Distinto do bloco estruturado “Dobrada”.',
},
},
{
name: 'nextSteps',
type: 'textarea',
label: 'Encaminhamentos',
maxLength: 4000,
admin: {
description: 'Próximos passos operacionais para o núcleo.',
},
},
{
name: 'voteGoals',
type: 'group',
label: 'Metas de votos 2026',
fields: [
{
name: 'good',
type: 'number',
label: 'Bom',
min: 0,
},
{
name: 'regular',
type: 'number',
label: 'Regular',
min: 0,
},
{
name: 'minimum',
type: 'number',
label: 'Mínimo',
min: 0,
},
],
},
{
name: 'priority',
type: 'select',
label: 'Prioridade',
defaultValue: 'normal',
index: true,
options: [
{ label: 'Alta', value: 'alta' },
{ label: 'Normal', value: 'normal' },
],
},
{
name: 'confirmedVoteEstimate',
type: 'number',

View file

@ -185,13 +185,17 @@ const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now:
<h2 id="campaign-kpis" className="sr-only">
Indicadores da campanha
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
<KpiCard label="Núcleos ativos" value={numberFormatter.format(view.kpis.activeNuclei)} />
<KpiCard
label="% com coordenador"
value={`${view.kpis.coordinatorCoveragePercent}%`}
progress={view.kpis.coordinatorCoveragePercent}
/>
<KpiCard
label="Meta regular (soma)"
value={`${numberFormatter.format(view.kpis.regularVoteGoalTotal)} votos`}
/>
<KpiCard
label="Estimativas confirmadas"
value={`${numberFormatter.format(view.kpis.confirmedVoteEstimateTotal)} votos`}

View file

@ -1,12 +1,7 @@
import type { Payload } from 'payload'
import { AlertTriangleIcon, CheckCircle2Icon, MessageCircleIcon, PhoneIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import {
AlertTriangleIcon,
CheckCircle2Icon,
MessageCircleIcon,
PhoneIcon,
} from 'lucide-react'
import type { Payload } from 'payload'
import { createNucleusUpdateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions'
import { searchPrimaryContactOptionsFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions'
@ -16,6 +11,7 @@ import { NucleusInsights } from '@/components/campaign/NucleusInsights'
import { NucleusIntelligenceDialogShell } from '@/components/campaign/NucleusIntelligenceDialogShell'
import { NucleusUpdateFeed } from '@/components/campaign/NucleusUpdateFeed'
import { NucleusUpdateFormShell } from '@/components/campaign/NucleusUpdateFormShell'
import { NucleusVoteGoals } from '@/components/campaign/NucleusVoteGoals'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -23,6 +19,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
import { Skeleton } from '@/components/ui/skeleton'
import type { CampaignUser } from '@/payload-types'
import { buildLeadershipPanelHref, parseLeadershipFilterState } from '@/utilities/leadershipUi'
import { loadNucleusActiveTabPageData } from '@/utilities/nucleusDetailPageData'
import {
buildNucleusDetailTabHref,
@ -30,21 +27,21 @@ import {
type NucleusDetailTab,
} from '@/utilities/nucleusDetailTabUi'
import type { AccessibleNucleusContext } from '@/utilities/nucleusPageData'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import {
formatNucleusTerritoryLabel,
nucleusDobradinhaNotesLabel,
nucleusNextStepsLabel,
} from '@/utilities/nucleusUi'
import {
parseNucleusUpdateListState,
type NucleusUpdateViewModel,
} from '@/utilities/nucleusUpdateUi'
import type {
NucleusDetailViewModel,
NucleusElectoralBaselineViewModel,
NucleusTabsViewModel,
StaffNucleusTabsViewModel,
} from '@/utilities/nucleusViewModels'
import {
buildLeadershipPanelHref,
parseLeadershipFilterState,
} from '@/utilities/leadershipUi'
import {
parseNucleusUpdateListState,
type NucleusUpdateViewModel,
} from '@/utilities/nucleusUpdateUi'
import { buildWhatsAppUrl } from '@/utilities/phone'
const OverviewContent = ({
@ -53,18 +50,22 @@ const OverviewContent = ({
confirmedVoteEstimate,
context,
nucleus,
priority,
primaryContact,
searchParams,
updatePreview,
voteGoals,
}: {
baseline: NucleusElectoralBaselineViewModel | null
canEditIntelligence: boolean
confirmedVoteEstimate: number | null
context: AccessibleNucleusContext
nucleus: NucleusTabsViewModel
priority: NucleusDetailViewModel['priority']
primaryContact: { id: number; name: string; phone: string } | null
searchParams: NucleusDetailSearchParams
updatePreview: NucleusUpdateViewModel[]
voteGoals: NucleusDetailViewModel['voteGoals']
}) => (
<>
{nucleus.kind === 'staff' ? (
@ -78,6 +79,8 @@ const OverviewContent = ({
risks: nucleus.risks,
voterProfiles: nucleus.voterProfiles,
ticketAlliance: nucleus.ticketAlliance,
dobradinhaNotes: nucleus.dobradinhaNotes,
nextSteps: nucleus.nextSteps,
}}
primaryContact={primaryContact}
searchPrimaryContacts={searchPrimaryContactOptionsFormAction.bind(null, context.slug)}
@ -129,9 +132,7 @@ const OverviewContent = ({
<div className="flex flex-wrap items-center gap-2">
<strong>{nucleus.ticketAlliance.partnerName}</strong>
{nucleus.ticketAlliance.office ? (
<span className="text-muted-foreground">
· {nucleus.ticketAlliance.office}
</span>
<span className="text-muted-foreground">· {nucleus.ticketAlliance.office}</span>
) : null}
<Badge
variant={
@ -189,6 +190,31 @@ const OverviewContent = ({
</CardContent>
</Card>
</div>
{nucleus.dobradinhaNotes || nucleus.nextSteps ? (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{nucleus.dobradinhaNotes ? (
<Card>
<CardHeader>
<CardTitle>{nucleusDobradinhaNotesLabel}</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{nucleus.dobradinhaNotes}</p>
</CardContent>
</Card>
) : null}
{nucleus.nextSteps ? (
<Card>
<CardHeader>
<CardTitle>{nucleusNextStepsLabel}</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{nucleus.nextSteps}</p>
</CardContent>
</Card>
) : null}
</div>
) : null}
</>
) : (
<Card>
@ -197,14 +223,17 @@ const OverviewContent = ({
<CardDescription>Informações básicas do território em que você atua</CardDescription>
</CardHeader>
<CardContent>
<p>
{formatNucleusTerritoryLabel(nucleus)}
</p>
<p>{formatNucleusTerritoryLabel(nucleus)}</p>
</CardContent>
</Card>
)}
<div className="mt-4 flex flex-col gap-4">
<NucleusVoteGoals
voteGoals={voteGoals}
confirmedVoteEstimate={confirmedVoteEstimate}
priority={priority}
/>
<NucleusElectoralBaseline baseline={baseline} />
<NucleusInsights baseline={baseline} confirmedVoteEstimate={confirmedVoteEstimate} />
</div>
@ -262,9 +291,7 @@ const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (
<CardDescription>Bahia</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p>
{formatNucleusTerritoryLabel(nucleus)}
</p>
<p>{formatNucleusTerritoryLabel(nucleus)}</p>
{nucleus.kind === 'staff' && nucleus.territoryNotes ? (
<p className="text-muted-foreground">{nucleus.territoryNotes}</p>
) : null}
@ -350,13 +377,7 @@ export const NucleusActiveTab = async ({
user: CampaignUser
view: NucleusDetailViewModel
}) => {
const data = await loadNucleusActiveTabPageData(
payload,
user,
context,
activeTab,
searchParams,
)
const data = await loadNucleusActiveTabPageData(payload, user, context, activeTab, searchParams)
if (data.tab === 'overview') {
return (
@ -366,9 +387,11 @@ export const NucleusActiveTab = async ({
confirmedVoteEstimate={view.confirmedVoteEstimate}
context={context}
nucleus={view.tabs}
priority={view.priority}
primaryContact={data.primaryContactPageData.current}
searchParams={searchParams}
updatePreview={data.updatePreview}
voteGoals={view.voteGoals}
/>
)
}

View file

@ -11,6 +11,7 @@ import {
} from '@/components/ui/card'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { cn } from '@/lib/utils'
import { nucleusPriorityLabels } from '@/utilities/nucleusUi'
const voteFormatter = new Intl.NumberFormat('pt-BR')
@ -21,6 +22,7 @@ export type NucleusCardProps = {
tseZones?: number[]
confirmedVoteEstimate?: number | null
hasPendingEstimate?: boolean
isHighPriority?: boolean
lastUpdateLabel?: string
isUpdateOverdue?: boolean
leadershipCounts?: {
@ -39,6 +41,7 @@ export const NucleusCard = ({
tseZones = [],
confirmedVoteEstimate,
hasPendingEstimate = false,
isHighPriority = false,
lastUpdateLabel,
isUpdateOverdue = false,
leadershipCounts,
@ -47,7 +50,10 @@ export const NucleusCard = ({
}: NucleusCardProps) => (
<Card className={className}>
<CardHeader>
<CardTitle>{name}</CardTitle>
<div className="flex flex-wrap items-start justify-between gap-2">
<CardTitle>{name}</CardTitle>
{isHighPriority ? <Badge variant="destructive">{nucleusPriorityLabels.alta}</Badge> : null}
</div>
<CardDescription>{[organization, territory].filter(Boolean).join(' · ')}</CardDescription>
{tseZones.length > 0 ? (
<div className="flex flex-wrap gap-1">

View file

@ -16,6 +16,7 @@ import {
buildNucleusListSearchParams,
nucleusListCoverageLabels,
nucleusListEstimateLabels,
nucleusPriorityFilterLabel,
type NucleusListState,
} from '@/utilities/nucleusUi'
import {
@ -30,6 +31,7 @@ type FilterValues = {
tseZone: string
coverage: string
estimate: string
priority: string
}
const valuesFromState = (state: NucleusListState): FilterValues => ({
@ -39,9 +41,10 @@ const valuesFromState = (state: NucleusListState): FilterValues => ({
tseZone: state.tseZone ? String(state.tseZone) : '',
coverage: state.coverage ?? '',
estimate: state.estimate ?? '',
priority: state.priority ?? '',
})
const filterNames = ['region', 'city', 'tseZone', 'coverage', 'estimate'] as const
const filterNames = ['region', 'city', 'tseZone', 'coverage', 'estimate', 'priority'] as const
type FilterFieldsProps = {
values: FilterValues
@ -61,7 +64,7 @@ const FilterFields = ({
<form
autoComplete="off"
onSubmit={(event) => event.preventDefault()}
className="grid gap-4 lg:grid-cols-5"
className="grid gap-4 lg:grid-cols-3 xl:grid-cols-6"
>
<Field>
<FieldLabel htmlFor="nucleus-lookup-a">Território de identidade</FieldLabel>
@ -136,6 +139,19 @@ const FilterFields = ({
</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="nucleus-priority">Prioridade</FieldLabel>
<NativeSelect
id="nucleus-priority"
name="priority"
value={values.priority}
onChange={(event) => updateFilter('priority', event.target.value)}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
<NativeSelectOption value="alta">{nucleusPriorityFilterLabel}</NativeSelectOption>
</NativeSelect>
</Field>
</form>
)
@ -160,6 +176,7 @@ export const NucleusFilters = ({ state }: { state: NucleusListState }) => {
tseZone: nextValues.tseZone ? Number(nextValues.tseZone) : undefined,
coverage: nextValues.coverage as NucleusListState['coverage'],
estimate: nextValues.estimate as NucleusListState['estimate'],
priority: nextValues.priority as NucleusListState['priority'],
})
const query = params.toString()
startTransition(() => {

View file

@ -1,7 +1,7 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { useActionState } from 'react'
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
@ -21,9 +21,13 @@ 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 type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
import { fieldError } from '@/utilities/campaignFormFields'
import { organizationKindLabels, sectorKindLabels } from '@/utilities/nucleusUi'
import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions'
import {
nucleusPriorityLabels,
organizationKindLabels,
sectorKindLabels,
} from '@/utilities/nucleusUi'
import type { NucleusFormViewModel } from '@/utilities/nucleusViewModels'
export type NucleusFormState = {
@ -210,6 +214,83 @@ export const NucleusFormFields = ({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metas 2026</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-3">
<Field data-invalid={Boolean(errorFor('voteGoals.good'))}>
<FieldLabel htmlFor="voteGoalsGood">Bom</FieldLabel>
<Input
id="voteGoalsGood"
name="voteGoalsGood"
type="number"
min={0}
step={1}
inputMode="numeric"
defaultValue={nucleus?.voteGoals?.good ?? ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('voteGoals.good'))}
/>
</Field>
<Field data-invalid={Boolean(errorFor('voteGoals.regular'))}>
<FieldLabel htmlFor="voteGoalsRegular">Regular</FieldLabel>
<Input
id="voteGoalsRegular"
name="voteGoalsRegular"
type="number"
min={0}
step={1}
inputMode="numeric"
defaultValue={nucleus?.voteGoals?.regular ?? ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('voteGoals.regular'))}
/>
</Field>
<Field data-invalid={Boolean(errorFor('voteGoals.minimum'))}>
<FieldLabel htmlFor="voteGoalsMinimum">Mínimo</FieldLabel>
<Input
id="voteGoalsMinimum"
name="voteGoalsMinimum"
type="number"
min={0}
step={1}
inputMode="numeric"
defaultValue={nucleus?.voteGoals?.minimum ?? ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('voteGoals.minimum'))}
/>
</Field>
</div>
{['voteGoals.good', 'voteGoals.regular', 'voteGoals.minimum'].map((field) =>
errorFor(field) ? (
<FieldError key={field} id={`${field}-error`}>
{errorFor(field)}
</FieldError>
) : null,
)}
<FieldDescription>
Quando informadas, as metas devem seguir a ordem Bom ≥ Regular ≥ Mínimo.
</FieldDescription>
<SelectField
id="priority"
label="Prioridade"
name="priority"
defaultValue={nucleus?.priority ?? 'normal'}
error={errorFor('priority')}
>
{Object.entries(nucleusPriorityLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</SelectField>
</FieldGroup>
</CardContent>
</Card>
{!nucleus ? (
<Card>
<CardHeader>
@ -229,10 +310,7 @@ export const NucleusFormFields = ({
{coordinators.map((coordinator) => (
<FieldLabel key={coordinator.id}>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
name="coordinators"
value={String(coordinator.id)}
/>
<Checkbox name="coordinators" value={String(coordinator.id)} />
<span>
{coordinator.name}
{coordinator.isCurrent ? ' (você)' : ''}

View file

@ -1,18 +1,18 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
updateNucleusIntelligenceFormAction,
type NucleusIntelligenceFormState,
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusIntelligenceFormActions'
import { PrimaryContactCombobox } from '@/components/campaign/PrimaryContactCombobox'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { PrimaryContactCombobox } from '@/components/campaign/PrimaryContactCombobox'
import {
DialogContent,
DialogDescription,
@ -32,12 +32,18 @@ import {
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { fieldError } from '@/utilities/campaignFormFields'
import {
nucleusDobradinhaNotesDescription,
nucleusDobradinhaNotesLabel,
nucleusNextStepsDescription,
nucleusNextStepsLabel,
} from '@/utilities/nucleusUi'
import type { StaffNucleusTabsViewModel } from '@/utilities/nucleusViewModels'
import type {
NucleusPrimaryContactPageData,
PrimaryContactOption,
} from '@/utilities/primaryContactPageData'
import type { StaffNucleusTabsViewModel } from '@/utilities/nucleusViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
type EditableProfile = StaffNucleusTabsViewModel['voterProfiles'][number]
@ -46,7 +52,7 @@ type NucleusIntelligenceDialogProps = {
nucleusId: number
intelligence: Pick<
StaffNucleusTabsViewModel,
'strengths' | 'risks' | 'voterProfiles' | 'ticketAlliance'
'strengths' | 'risks' | 'voterProfiles' | 'ticketAlliance' | 'dobradinhaNotes' | 'nextSteps'
>
primaryContact: PrimaryContactOption | null
searchPrimaryContacts: (query: string) => Promise<NucleusPrimaryContactPageData>
@ -357,6 +363,40 @@ export const NucleusIntelligenceDialog = ({
</Button>
</FieldSet>
<FieldSet>
<FieldLegend>{nucleusDobradinhaNotesLabel}</FieldLegend>
<FieldDescription>{nucleusDobradinhaNotesDescription}</FieldDescription>
<Field>
<FieldLabel htmlFor="dobradinha-notes" className="sr-only">
{nucleusDobradinhaNotesLabel}
</FieldLabel>
<Textarea
id="dobradinha-notes"
name="dobradinhaNotes"
maxLength={4000}
className="min-h-24"
defaultValue={intelligence.dobradinhaNotes ?? ''}
/>
</Field>
</FieldSet>
<FieldSet>
<FieldLegend>{nucleusNextStepsLabel}</FieldLegend>
<FieldDescription>{nucleusNextStepsDescription}</FieldDescription>
<Field>
<FieldLabel htmlFor="next-steps" className="sr-only">
{nucleusNextStepsLabel}
</FieldLabel>
<Textarea
id="next-steps"
name="nextSteps"
maxLength={4000}
className="min-h-24"
defaultValue={intelligence.nextSteps ?? ''}
/>
</Field>
</FieldSet>
<FieldSet>
<FieldLegend>Dobrada</FieldLegend>
<div className="grid gap-3 sm:grid-cols-2">

View file

@ -31,7 +31,7 @@ export const NucleusIntelligenceDialogShell = ({
nucleusId: number
intelligence: Pick<
StaffNucleusTabsViewModel,
'strengths' | 'risks' | 'voterProfiles' | 'ticketAlliance'
'strengths' | 'risks' | 'voterProfiles' | 'ticketAlliance' | 'dobradinhaNotes' | 'nextSteps'
>
primaryContact: PrimaryContactOption | null
searchPrimaryContacts: (query: string) => Promise<NucleusPrimaryContactPageData>

View file

@ -14,7 +14,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi'
import { formatNucleusTerritoryLabel, nucleusPriorityLabels, organizationKindLabels } from '@/utilities/nucleusUi'
import type { NucleusListViewModel } from '@/utilities/nucleusViewModels'
export type NucleusListProps = {
@ -68,6 +68,7 @@ export const NucleusList = ({ nuclei }: NucleusListProps) => (
tseZones={nucleus.tseZones}
confirmedVoteEstimate={nucleus.confirmedVoteEstimate}
hasPendingEstimate={nucleus.proposedVoteEstimate != null}
isHighPriority={nucleus.priority === 'alta'}
lastUpdateLabel={
nucleus.lastUpdateAt
? `Atualizada em ${new Intl.DateTimeFormat('pt-BR').format(new Date(nucleus.lastUpdateAt))}`
@ -102,12 +103,17 @@ export const NucleusList = ({ nuclei }: NucleusListProps) => (
return (
<TableRow key={nucleus.id}>
<TableCell className="max-w-52 whitespace-normal">
<Link
href={`/campanha/nucleos/${nucleus.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{nucleus.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/campanha/nucleos/${nucleus.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{nucleus.name}
</Link>
{nucleus.priority === 'alta' ? (
<Badge variant="destructive">{nucleusPriorityLabels.alta}</Badge>
) : null}
</div>
</TableCell>
<TableCell className="max-w-56 whitespace-normal">
<span className="font-medium">{getOrganization(nucleus)}</span>

View file

@ -87,6 +87,28 @@ export const NucleusListOverview = ({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription>Metas 2026</CardDescription>
<CardTitle className="text-3xl tabular-nums">
{numberFormatter.format(view.voteGoals.regular)}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Soma do cenário regular no conjunto filtrado
</p>
<p className="text-sm">
Bom: {numberFormatter.format(view.voteGoals.good)} · Mínimo:{' '}
{numberFormatter.format(view.voteGoals.minimum)}
</p>
<p className="text-sm font-medium">
{numberFormatter.format(view.highPriorityCount)}{' '}
{view.highPriorityCount === 1 ? 'núcleo prioritário' : 'núcleos prioritários'}
</p>
</CardContent>
</Card>
{view.baseline2022 ? (
<Card>
<CardHeader>

View file

@ -10,6 +10,11 @@ import {
trimmedNullableText,
trimmedOptionalText,
} from '@/lib/schemas/primitives'
import {
getVoteGoalsOrderViolation,
VOTE_GOALS_ORDER_ERROR_MESSAGE,
type VoteGoalsFields,
} from '@/utilities/voteGoals'
export const organizationKinds = [
'territorial',
@ -33,6 +38,12 @@ export const sectorKinds = [
'outro',
] as const
export const nucleusPriorities = ['alta', 'normal'] as const
export type NucleusPriority = (typeof nucleusPriorities)[number]
export type VoteGoalsInput = VoteGoalsFields
export const MAX_NUCLEUS_REGIONS = 27
export const MAX_NUCLEUS_CITIES = 27
export const MAX_NUCLEUS_NEIGHBORHOODS = 30
@ -111,6 +122,29 @@ const ticketAllianceSchema = z.object({
notes: trimmedOptionalText(1000),
})
const voteGoalValueSchema = z.number().int().min(0).nullable().optional()
const voteGoalsSchema = z.object({
good: voteGoalValueSchema,
regular: voteGoalValueSchema,
minimum: voteGoalValueSchema,
})
const validateVoteGoalsOrder = (
voteGoals: VoteGoalsInput | null | undefined,
context: z.RefinementCtx,
path: (string | number)[],
) => {
const violation = getVoteGoalsOrderViolation(voteGoals)
if (!violation) return
context.addIssue({
code: 'custom',
message: VOTE_GOALS_ORDER_ERROR_MESSAGE,
path: [...path, violation],
})
}
const nucleusFieldsSchema = z.object({
name: z.string().trim().min(2).max(160),
regions: regionsArraySchema.optional(),
@ -127,6 +161,10 @@ const nucleusFieldsSchema = z.object({
strengths: z.array(insightSchema).optional(),
risks: z.array(insightSchema).optional(),
ticketAlliance: ticketAllianceSchema.optional(),
dobradinhaNotes: trimmedOptionalText(4000),
nextSteps: trimmedOptionalText(4000),
voteGoals: voteGoalsSchema.optional(),
priority: z.enum(nucleusPriorities).optional(),
})
type TerritoryValidationInput = {
@ -212,11 +250,16 @@ export const nucleusCreateSchema = nucleusFieldsSchema
coordinators: z.array(positiveRelationshipId).optional(),
organizationKind: z.enum(organizationKinds).default('territorial'),
})
.superRefine((data, context) => validateTerritoryAndZones(data, context, 'create'))
.superRefine((data, context) => {
validateTerritoryAndZones(data, context, 'create')
validateVoteGoalsOrder(data.voteGoals, context, ['voteGoals'])
})
.transform((data) => {
const cities = data.cities ?? []
const regions =
cities.length > 0 ? territoriesForCities(cities) : (data.regions ?? []).filter(isBahiaIdentityTerritory)
cities.length > 0
? territoriesForCities(cities)
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
const neighborhoods = cities.length === 1 ? (data.neighborhoods ?? []) : []
return {
...data,
@ -238,6 +281,8 @@ export const nucleusUpdateSchema = nucleusFieldsSchema
organizationLabel: trimmedNullableText(160),
sectorKind: z.enum(sectorKinds).nullable().optional(),
primaryContact: positiveRelationshipId.nullable().optional(),
voteGoals: voteGoalsSchema.nullable().optional(),
priority: z.enum(nucleusPriorities).nullable().optional(),
ticketAlliance: z
.object({
partnerName: trimmedNullableText(120),
@ -247,7 +292,12 @@ export const nucleusUpdateSchema = nucleusFieldsSchema
})
.optional(),
})
.superRefine((data, context) => validateTerritoryAndZones(data, context, 'patch'))
.superRefine((data, context) => {
validateTerritoryAndZones(data, context, 'patch')
if (data.voteGoals !== undefined) {
validateVoteGoalsOrder(data.voteGoals, context, ['voteGoals'])
}
})
.transform((data) => {
const cities = data.cities === undefined ? undefined : (data.cities ?? [])
const neighborhoods =

View file

@ -11,6 +11,7 @@ import * as migration_20260718_222832_add_action_plan from './20260718_222832_ad
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch'
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf'
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index'
import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy'
import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global'
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy'
@ -80,6 +81,11 @@ export const migrations = [
down: migration_20260719_020000_add_contact_trgm_index.down,
name: '20260719_020000_add_contact_trgm_index',
},
{
up: migration_20260719_054522_add_nucleus_goals_strategy.up,
down: migration_20260719_054522_add_nucleus_goals_strategy.down,
name: '20260719_054522_add_nucleus_goals_strategy',
},
{
up: migration_20260719_054706_add_privacy_policy_global.up,
down: migration_20260719_054706_add_privacy_policy_global.down,

View file

@ -407,6 +407,20 @@ export interface ElectoralNucleus {
id?: string | null;
}[]
| null;
/**
* Quem dobra no território hoje e o estado da negociação. Distinto do bloco estruturado “Dobrada”.
*/
dobradinhaNotes?: string | null;
/**
* Próximos passos operacionais para o núcleo.
*/
nextSteps?: string | null;
voteGoals?: {
good?: number | null;
regular?: number | null;
minimum?: number | null;
};
priority?: ('alta' | 'normal') | null;
confirmedVoteEstimate?: number | null;
confirmedVoteEstimateAt?: string | null;
confirmedVoteEstimateBy?: (number | null) | CampaignUser;
@ -1155,6 +1169,16 @@ export interface ElectoralNucleusSelect<T extends boolean = true> {
text?: T;
id?: T;
};
dobradinhaNotes?: T;
nextSteps?: T;
voteGoals?:
| T
| {
good?: T;
regular?: T;
minimum?: T;
};
priority?: T;
confirmedVoteEstimate?: T;
confirmedVoteEstimateAt?: T;
confirmedVoteEstimateBy?: T;

View file

@ -1,19 +1,21 @@
import type { Payload } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { isSupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import {
buildGeneralDashboardViewModel,
buildScopedDashboardViewModel,
type DashboardLeadershipRecord,
type DashboardNucleusRecord,
} from '@/utilities/campaignDashboardViewModels'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'
const dashboardStaffNucleusSelect = {
const dashboardNucleusSelectBase = {
name: true,
slug: true,
regions: true,
@ -29,6 +31,13 @@ const dashboardStaffNucleusSelect = {
lastUpdateAt: true,
} as const
const dashboardGeralNucleusSelect = {
...dashboardNucleusSelectBase,
voteGoals: nucleusVoteGoalsSelect.voteGoals,
} as const
const dashboardCoordinatorNucleusSelect = dashboardNucleusSelectBase
const dashboardLeadershipNucleusSelect = {
name: true,
slug: true,
@ -62,6 +71,12 @@ type RawDashboardNucleus = {
tseZones?: Array<{ zoneNumber: number }>
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
voteGoals?: {
good?: number | null
regular?: number | null
minimum?: number | null
} | null
priority?: DashboardNucleusRecord['priority'] | null
lastUpdateAt?: string | null
}
@ -94,6 +109,8 @@ const toDashboardNucleusRecord = (
tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [],
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
voteGoals: toVoteGoalsViewModel(nucleus.voteGoals),
priority: nucleus.priority ?? 'normal',
lastUpdateAt: nucleus.lastUpdateAt ?? null,
})
@ -111,7 +128,11 @@ export const getCampaignDashboardPageData = async (
pagination: false,
sort: 'name',
select:
user.role === 'lideranca' ? dashboardLeadershipNucleusSelect : dashboardStaffNucleusSelect,
user.role === 'lideranca'
? dashboardLeadershipNucleusSelect
: user.role === 'geral'
? dashboardGeralNucleusSelect
: dashboardCoordinatorNucleusSelect,
user,
overrideAccess: false,
}),
@ -167,23 +188,23 @@ export const getCampaignDashboardPageData = async (
const nuclei = rawNuclei.map((nucleus) =>
toDashboardNucleusRecord(nucleus, coordinatorIdentities),
)
const leaderships = (
leadershipResult.docs as unknown as RawDashboardLeadership[]
).flatMap((leadership): DashboardLeadershipRecord[] => {
// Payload omits fields denied by field-level read access. A missing status is
// therefore an intentionally incomplete projection, not a domain value.
if (leadership.supportStatus === undefined) return []
if (!isSupportStatus(leadership.supportStatus)) {
throw new Error('Dashboard leadership support status invariant violated.')
}
const leaderships = (leadershipResult.docs as unknown as RawDashboardLeadership[]).flatMap(
(leadership): DashboardLeadershipRecord[] => {
// Payload omits fields denied by field-level read access. A missing status is
// therefore an intentionally incomplete projection, not a domain value.
if (leadership.supportStatus === undefined) return []
if (!isSupportStatus(leadership.supportStatus)) {
throw new Error('Dashboard leadership support status invariant violated.')
}
return [
{
nucleusId: requireRelationshipId(leadership.nucleus),
supportStatus: leadership.supportStatus,
},
]
})
return [
{
nucleusId: requireRelationshipId(leadership.nucleus),
supportStatus: leadership.supportStatus,
},
]
},
)
if (user.role === 'geral') {
return buildGeneralDashboardViewModel(

View file

@ -1,7 +1,9 @@
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { SupportStatus } from '@/lib/schemas/leadership'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import { sumVoteGoals, type VoteGoalsViewModel } from '@/utilities/voteGoals'
export type CoordinatorSummary = {
id: number
@ -23,6 +25,8 @@ export type DashboardNucleusRecord = {
tseZones: number[]
confirmedVoteEstimate: number | null
proposedVoteEstimate: number | null
voteGoals: VoteGoalsViewModel
priority: NucleusPriority
lastUpdateAt: string | null
}
@ -49,6 +53,7 @@ export type GeneralDashboardViewModel = {
coordinatorCoveragePercent: number
confirmedVoteEstimateTotal: number
confirmedEstimatePercent: number
regularVoteGoalTotal: number
updatesThisWeek: number
}
supportCounts: Record<SupportStatus, number>
@ -190,6 +195,7 @@ export const buildGeneralDashboardViewModel = (
0,
),
confirmedEstimatePercent: percentage(confirmedCount, nuclei.length),
regularVoteGoalTotal: sumVoteGoals(nuclei).regular,
updatesThisWeek,
},
supportCounts,

View file

@ -2,6 +2,7 @@ import 'server-only'
import {
organizationKinds,
nucleusPriorities,
sectorKinds,
type NucleusCreateInput,
type NucleusUpdateInput,
@ -9,6 +10,7 @@ import {
import {
checkboxFormValue,
optionalFormText,
optionalIntegerFormValue,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -18,6 +20,34 @@ import {
} from '@/utilities/campaignTerritoryFormData'
import { parseTseZoneNumbers } from '@/utilities/tseZone'
const parseNullableVoteGoal = (formData: FormData, field: string): number | null | undefined => {
if (!formData.has(field)) return undefined
const value = optionalFormText(formData, field)
if (!value) return null
return optionalIntegerFormValue(formData, field, { minimum: 0 })
}
const parseVoteGoalsAndPriority = (formData: FormData) => {
const hasVoteGoals = ['voteGoalsGood', 'voteGoalsRegular', 'voteGoalsMinimum'].some((field) =>
formData.has(field),
)
const priorityRaw = formData.has('priority') ? optionalFormText(formData, 'priority') : undefined
const priority = nucleusPriorities.find((value) => value === priorityRaw)
return {
...(hasVoteGoals
? {
voteGoals: {
good: parseNullableVoteGoal(formData, 'voteGoalsGood'),
regular: parseNullableVoteGoal(formData, 'voteGoalsRegular'),
minimum: parseNullableVoteGoal(formData, 'voteGoalsMinimum'),
},
}
: {}),
...(priority ? { priority } : {}),
}
}
const parseZoneNumbers = (value: string | undefined): Array<{ zoneNumber: number }> | undefined =>
value ? parseTseZoneNumbers(value).map((zoneNumber) => ({ zoneNumber })) : undefined
@ -52,6 +82,7 @@ export const parseNucleusCreateFormData = (formData: FormData): NucleusCreateInp
return {
...shared,
...parseVoteGoalsAndPriority(formData),
coordinators: coordinatorIds.length ? coordinatorIds : undefined,
}
}
@ -63,6 +94,7 @@ export const parseNucleusUpdateFormData = (formData: FormData): NucleusUpdateInp
return {
...shared,
...parseVoteGoalsAndPriority(formData),
id: requiredRelationshipFormValue(formData, 'id'),
regions: shared.regions,
cities: shared.cities,

View file

@ -11,6 +11,7 @@ export const parseNucleusIntelligenceFormData = (formData: FormData): NucleusUpd
const hasTicketAlliance = ['partnerName', 'office', 'isCampaignPartner', 'allianceNotes'].some(
(field) => formData.has(field),
)
const hasStrategyNotes = ['dobradinhaNotes', 'nextSteps'].some((field) => formData.has(field))
return nucleusUpdateSchema.parse({
id: requiredRelationshipFormValue(formData, 'nucleus'),
@ -18,6 +19,12 @@ export const parseNucleusIntelligenceFormData = (formData: FormData): NucleusUpd
risks: boundedJsonFormValue(formData, 'risks', 100_000),
voterProfiles: boundedJsonFormValue(formData, 'voterProfiles', 100_000),
primaryContact: nullableRelationshipFormValue(formData, 'primaryContact'),
...(hasStrategyNotes
? {
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
nextSteps: nullableFormText(formData, 'nextSteps'),
}
: {}),
ticketAlliance: hasTicketAlliance
? {
partnerName: nullableFormText(formData, 'partnerName'),

View file

@ -14,9 +14,11 @@ import {
type NucleusListOverviewViewModel,
} from '@/utilities/nucleusListOverviewViewModels'
import { buildNucleusListWhere, type NucleusListState } from '@/utilities/nucleusUi'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'
const overviewStaffNucleusSelect = {
const overviewNucleusSelectBase = {
slug: true,
name: true,
coordinators: true,
@ -24,18 +26,15 @@ const overviewStaffNucleusSelect = {
regions: true,
tseZones: { zoneNumber: true },
confirmedVoteEstimate: true,
...nucleusVoteGoalsSelect,
} as const
const overviewStaffNucleusSelect = {
...overviewNucleusSelectBase,
proposedVoteEstimate: true,
} as const
const overviewLeadershipNucleusSelect = {
slug: true,
name: true,
coordinators: true,
cities: true,
regions: true,
tseZones: { zoneNumber: true },
confirmedVoteEstimate: true,
} as const
const overviewLeadershipNucleusSelect = overviewNucleusSelectBase
const overviewUpdateSelect = {
author: true,
@ -54,6 +53,12 @@ type RawOverviewNucleus = {
tseZones?: Array<{ zoneNumber: number }> | null
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
voteGoals?: {
good?: number | null
regular?: number | null
minimum?: number | null
} | null
priority?: NucleusListOverviewNucleusRecord['priority'] | null
}
type RawOverviewUpdate = {
@ -102,6 +107,8 @@ export const loadNucleusListOverviewData = async (
...geography,
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
voteGoals: toVoteGoalsViewModel(nucleus.voteGoals),
priority: nucleus.priority ?? 'normal',
}
})

View file

@ -1,6 +1,12 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import {
aggregateVoteGoals,
type VoteGoalsSumViewModel,
type VoteGoalsViewModel,
} from '@/utilities/voteGoals'
export const nucleusListOverviewPreviewLimit = 3
@ -14,6 +20,8 @@ export type NucleusListOverviewNucleusRecord = {
tseZones: number[]
confirmedVoteEstimate: number | null
proposedVoteEstimate: number | null
voteGoals: VoteGoalsViewModel
priority: NucleusPriority
}
export type NucleusListOverviewBaseline2022 = NucleusBaseline2022OverviewAggregate
@ -42,6 +50,8 @@ export type NucleusListOverviewViewModel = {
}
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
baseline2022: NucleusListOverviewBaseline2022 | null
voteGoals: VoteGoalsSumViewModel
highPriorityCount: number
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@ -67,6 +77,7 @@ export const buildNucleusListOverviewViewModel = ({
({ confirmedVoteEstimate }) => confirmedVoteEstimate !== null,
).length
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
const { highPriorityCount, ...voteGoals } = aggregateVoteGoals(nuclei)
return {
totalFiltered,
@ -91,6 +102,8 @@ export const buildNucleusListOverviewViewModel = ({
percent: percentage(coordinatedCount, totalFiltered),
},
baseline2022,
voteGoals,
highPriorityCount,
recentUpdates,
upcomingActionPlans,
}

View file

@ -3,9 +3,10 @@ import type { Where } from 'payload'
import {
bahiaIdentityTerritories,
bahiaMunicipalities,
type BahiaIdentityTerritory,
territoryForCity,
type BahiaIdentityTerritory,
} from '@/lib/bahiaTerritories'
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import {
buildListHref,
@ -41,6 +42,19 @@ export const sectorKindLabels: Record<NonNullable<ElectoralNucleus['sectorKind']
outro: 'Outro',
}
export const nucleusPriorityLabels: Record<NucleusPriority, string> = {
alta: 'Prioritário',
normal: 'Normal',
}
export const nucleusPriorityFilterLabel = 'Prioritários'
export const nucleusDobradinhaNotesLabel = 'Dobradinhas (notas)'
export const nucleusDobradinhaNotesDescription =
'Quem dobra no território hoje e o estado da negociação. Distinto do bloco estruturado “Dobrada”.'
export const nucleusNextStepsLabel = 'Encaminhamentos'
export const nucleusNextStepsDescription = 'Próximos passos operacionais para o núcleo.'
export const formatNucleusTerritoryLabel = ({
neighborhoods = [],
locality,
@ -69,6 +83,7 @@ export type NucleusListState = {
tseZone?: number
coverage?: 'com_coordenador' | 'sem_coordenador'
estimate?: 'confirmada' | 'sem_confirmacao'
priority?: 'alta'
}
type RawSearchParams = CampaignListRawSearchParams
@ -80,6 +95,7 @@ export const nucleusListParamNames = [
'tseZone',
'coverage',
'estimate',
'priority',
'page',
] as const
@ -116,6 +132,7 @@ export const parseNucleusListParams = (params: RawSearchParams): NucleusListStat
const rawTseZone = strictDecimalInteger(firstValue(params.tseZone))
const rawCoverage = firstValue(params.coverage)
const rawEstimate = firstValue(params.estimate)
const rawPriority = firstValue(params.priority)
return {
page: rawPage ?? 1,
@ -129,6 +146,7 @@ export const parseNucleusListParams = (params: RawSearchParams): NucleusListStat
...(rawEstimate === 'confirmada' || rawEstimate === 'sem_confirmacao'
? { estimate: rawEstimate }
: {}),
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
}
}
@ -158,6 +176,9 @@ export const buildNucleusListWhere = (state: NucleusListState): Where => {
confirmedVoteEstimate: { exists: state.estimate === 'confirmada' },
})
}
if (state.priority) {
filters.push({ priority: { equals: state.priority } })
}
return { and: filters }
}
@ -174,6 +195,7 @@ export const buildNucleusListSearchParams = (
tseZone: state.tseZone === undefined ? undefined : String(state.tseZone),
coverage: state.coverage,
estimate: state.estimate,
priority: state.priority,
})
const params = new URLSearchParams()
@ -183,6 +205,7 @@ export const buildNucleusListSearchParams = (
if (canonicalState.tseZone) params.set('tseZone', String(canonicalState.tseZone))
if (canonicalState.coverage) params.set('coverage', canonicalState.coverage)
if (canonicalState.estimate) params.set('estimate', canonicalState.estimate)
if (canonicalState.priority) params.set('priority', canonicalState.priority)
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params

View file

@ -1,9 +1,20 @@
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals'
const asStringArray = (value: string[] | null | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
export const nucleusVoteGoalsSelect = {
voteGoals: {
good: true,
regular: true,
minimum: true,
},
priority: true,
} as const
export const nucleusFormSelect = {
name: true,
slug: true,
@ -17,6 +28,7 @@ export const nucleusFormSelect = {
sectorKind: true,
tseZones: true,
ticketAlliance: true,
...nucleusVoteGoalsSelect,
} as const
export const nucleusListSelect = {
@ -32,6 +44,7 @@ export const nucleusListSelect = {
tseZones: true,
confirmedVoteEstimate: true,
proposedVoteEstimate: true,
priority: true,
lastUpdateAt: true,
} as const
@ -49,6 +62,7 @@ export type NucleusListViewModel = {
tseZones: number[]
confirmedVoteEstimate: number | null
proposedVoteEstimate: number | null
priority: NucleusPriority
lastUpdateAt: string | null
}
@ -88,6 +102,9 @@ export const nucleusStaffDetailSelect = {
proposedVoteEstimateBy: true,
proposedVoteEstimateVersion: true,
primaryContact: true,
dobradinhaNotes: true,
nextSteps: true,
...nucleusVoteGoalsSelect,
} as const
export const nucleusLeadershipDetailSelect = {
@ -102,6 +119,7 @@ export const nucleusLeadershipDetailSelect = {
organizationLabel: true,
tseZones: { zoneNumber: true },
confirmedVoteEstimate: true,
...nucleusVoteGoalsSelect,
} as const
export type NucleusFormViewModel = {
@ -123,6 +141,8 @@ export type NucleusFormViewModel = {
isCampaignPartner: boolean
notes: string | null
} | null
voteGoals: VoteGoalsViewModel
priority: NucleusPriority
}
export type StaffNucleusTabsViewModel = {
@ -149,6 +169,8 @@ export type StaffNucleusTabsViewModel = {
isCampaignPartner: boolean
notes: string | null
} | null
dobradinhaNotes: string | null
nextSteps: string | null
}
export type LeadershipNucleusTabsViewModel = {
@ -175,6 +197,8 @@ type NucleusDetailBaseViewModel = {
organizationLabel: string | null
tseZones: number[]
confirmedVoteEstimate: number | null
voteGoals: VoteGoalsViewModel
priority: NucleusPriority
}
export type StaffNucleusDetailViewModel = NucleusDetailBaseViewModel & {
@ -237,6 +261,7 @@ export const toNucleusListViewModel = (nucleus: ElectoralNucleus): NucleusListVi
tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [],
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
proposedVoteEstimate: nucleus.proposedVoteEstimate ?? null,
priority: nucleus.priority ?? 'normal',
lastUpdateAt: nucleus.lastUpdateAt ?? null,
})
@ -261,6 +286,8 @@ export const toNucleusFormViewModel = (nucleus: ElectoralNucleus): NucleusFormVi
notes: nucleus.ticketAlliance.notes ?? null,
}
: null,
voteGoals: toVoteGoalsViewModel(nucleus.voteGoals),
priority: nucleus.priority ?? 'normal',
})
const toNucleusDetailBaseViewModel = (nucleus: ElectoralNucleus): NucleusDetailBaseViewModel => ({
@ -276,6 +303,8 @@ const toNucleusDetailBaseViewModel = (nucleus: ElectoralNucleus): NucleusDetailB
organizationLabel: nucleus.organizationLabel ?? null,
tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [],
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
voteGoals: toVoteGoalsViewModel(nucleus.voteGoals),
priority: nucleus.priority ?? 'normal',
})
const toStaffNucleusDetailViewModel = (nucleus: ElectoralNucleus): StaffNucleusDetailViewModel => ({
@ -344,6 +373,8 @@ export const toStaffNucleusTabsViewModel = (
notes: nucleus.ticketAlliance.notes ?? null,
}
: null,
dobradinhaNotes: nucleus.dobradinhaNotes ?? null,
nextSteps: nucleus.nextSteps ?? null,
})
export const toLeadershipNucleusTabsViewModel = (

View file

@ -963,6 +963,36 @@ describe('electoral nucleus domain', () => {
expect(clearedLocality.locality).toBeNull()
})
it('persists vote goals, priority, and E3 strategy fields', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const nucleus = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com metas'),
coordinators: [general.id],
voteGoals: { good: 1000, regular: 800, minimum: 500 },
priority: 'alta',
dobradinhaNotes: 'Notas de dobradinha',
nextSteps: 'Próximos encaminhamentos',
})
expect(nucleus.voteGoals).toMatchObject({
good: 1000,
regular: 800,
minimum: 500,
})
expect(nucleus.priority).toBe('alta')
expect(nucleus.dobradinhaNotes).toBe('Notas de dobradinha')
expect(nucleus.nextSteps).toBe('Próximos encaminhamentos')
await expect(
updateElectoralNucleus(payload, general, {
id: nucleus.id,
voteGoals: { good: 500, regular: 800, minimum: 500 },
}),
).rejects.toThrow()
})
it('drops lock-document dependencies before the nucleus table on rollback', () => {
const migration = readFileSync(
new URL(

View file

@ -50,6 +50,8 @@ const intelligenceProps = {
risks: [],
voterProfiles: [],
ticketAlliance: null,
dobradinhaNotes: null,
nextSteps: null,
},
primaryContact: null,
searchPrimaryContacts: async () => ({ current: null, options: [] }),

View file

@ -81,6 +81,7 @@ describe('campaign nucleus automatic filters', () => {
expect(screen.getAllByLabelText('Nº da ZE')).toHaveLength(1)
expect(screen.getAllByLabelText('Cobertura')).toHaveLength(1)
expect(screen.getAllByLabelText('Estimativa')).toHaveLength(1)
expect(screen.getAllByLabelText('Prioridade')).toHaveLength(1)
const lookups = [
screen.getByLabelText('Território de identidade'),
screen.getByLabelText('Município'),
@ -315,6 +316,7 @@ describe('campaign nucleus server filter parsing', () => {
'tseZone',
'coverage',
'estimate',
'priority',
'page',
])
expect(

View file

@ -143,6 +143,8 @@ describe('campaign nucleus intelligence UI', () => {
isCampaignPartner: true,
notes: 'Agenda conjunta',
},
dobradinhaNotes: null,
nextSteps: null,
},
primaryContact: { id: 8, name: 'Maria', phone: '71999990000' },
searchPrimaryContacts: async () => ({
@ -226,6 +228,8 @@ describe('campaign nucleus intelligence UI', () => {
formData.set('office', 'Deputada estadual')
formData.set('isCampaignPartner', 'on')
formData.set('allianceNotes', 'Agenda conjunta')
formData.set('dobradinhaNotes', 'Negociação em curso')
formData.set('nextSteps', 'Visitar prefeito')
formData.set('confirmedVoteEstimate', '999999')
formData.set('confirmedVoteEstimateBy', '999')
@ -241,6 +245,8 @@ describe('campaign nucleus intelligence UI', () => {
isCampaignPartner: true,
notes: 'Agenda conjunta',
},
dobradinhaNotes: 'Negociação em curso',
nextSteps: 'Visitar prefeito',
})
})
@ -341,6 +347,8 @@ describe('campaign nucleus intelligence UI', () => {
isCampaignPartner: true,
notes: 'Agenda sigilosa',
},
voteGoals: { good: 100, regular: 80, minimum: 50 },
priority: 'alta',
confirmedVoteEstimate: 1200,
confirmedVoteEstimateAt: '2026-07-16T12:00:00.000Z',
confirmedVoteEstimateBy: {
@ -384,6 +392,8 @@ describe('campaign nucleus intelligence UI', () => {
organizationLabel: 'Organização local',
tseZones: [58],
confirmedVoteEstimate: 1200,
voteGoals: { good: 100, regular: 80, minimum: 50 },
priority: 'alta',
tabs: {
kind: 'leadership',
regions: ['Chapada Diamantina'],
@ -398,6 +408,8 @@ describe('campaign nucleus intelligence UI', () => {
'risks',
'voterProfiles',
'ticketAlliance',
'dobradinhaNotes',
'nextSteps',
'territoryNotes',
'latitude',
'longitude',

View file

@ -15,6 +15,8 @@ const nucleus = ({
tseZones = [1],
confirmedVoteEstimate = null,
proposedVoteEstimate = null,
voteGoals = { good: null, regular: null, minimum: null },
priority = 'normal' as const,
}: {
id: number
coordinators?: number[]
@ -23,6 +25,8 @@ const nucleus = ({
tseZones?: number[]
confirmedVoteEstimate?: number | null
proposedVoteEstimate?: number | null
voteGoals?: { good: number | null; regular: number | null; minimum: number | null }
priority?: 'alta' | 'normal'
}) => ({
id,
slug: `nucleo-${id}`,
@ -33,6 +37,8 @@ const nucleus = ({
tseZones,
confirmedVoteEstimate,
proposedVoteEstimate,
voteGoals,
priority,
})
describe('buildNucleusListOverviewViewModel', () => {
@ -66,6 +72,8 @@ describe('buildNucleusListOverviewViewModel', () => {
coordinatedCount: 2,
percent: 50,
})
expect(view.voteGoals).toEqual({ good: 0, regular: 0, minimum: 0 })
expect(view.highPriorityCount).toBe(0)
})
it('omits pending suggestions for lideranca', () => {
@ -83,6 +91,29 @@ describe('buildNucleusListOverviewViewModel', () => {
expect(view.estimate.confirmedTotal).toBe(800)
expect(view.estimate.confirmedPercent).toBe(50)
})
it('aggregates vote goals and prioritários in the filtered set', () => {
const view = buildNucleusListOverviewViewModel({
role: 'geral',
nuclei: [
nucleus({
id: 1,
voteGoals: { good: 1000, regular: 800, minimum: 500 },
priority: 'alta',
}),
nucleus({
id: 2,
voteGoals: { good: 200, regular: 150, minimum: 100 },
priority: 'normal',
}),
],
recentUpdates: [],
upcomingActionPlans: [],
})
expect(view.voteGoals).toEqual({ good: 1200, regular: 950, minimum: 600 })
expect(view.highPriorityCount).toBe(1)
})
})
describe('NucleusListOverview', () => {
@ -112,6 +143,7 @@ describe('NucleusListOverview', () => {
expect(html).toContain('1.200')
expect(html).toContain('50% com estimativa confirmada')
expect(html).toContain('1 de 2 com coordenador')
expect(html).toContain('Metas 2026')
expect(html).toContain('Últimas atualizações')
expect(html).toContain('Maria')
expect(html).toContain('Semanal')

View file

@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { archiveNucleusFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/formActions'
@ -11,6 +11,7 @@ import { NucleusList } from '@/components/campaign/NucleusList'
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import { parseNucleusCreateFormData, parseNucleusUpdateFormData } from '@/utilities/nucleusFormData'
import {
buildNucleusListHref,
buildNucleusListWhere,
@ -19,10 +20,6 @@ import {
getCampaignScopeLabel,
parseNucleusListParams,
} from '@/utilities/nucleusUi'
import {
parseNucleusCreateFormData,
parseNucleusUpdateFormData,
} from '@/utilities/nucleusFormData'
import { nucleusStaffDetailSelect, toNucleusDetailViewModel } from '@/utilities/nucleusViewModels'
import { parseTseZoneNumbers } from '@/utilities/tseZone'
import { matchesAtWordStart } from '@/utilities/wordStartFilter'
@ -137,9 +134,7 @@ describe('campaign nucleus UI contracts', () => {
expect(region.id).not.toBe(city.id)
expect(container.querySelectorAll('input[type="hidden"][name="cities"]')).toHaveLength(0)
expect(container.querySelectorAll('input[type="hidden"][name="regions"]')).toHaveLength(0)
expect(container.querySelectorAll('input[type="hidden"][name="neighborhoods"]')).toHaveLength(
0,
)
expect(container.querySelectorAll('input[type="hidden"][name="neighborhoods"]')).toHaveLength(0)
expect(region.getAttribute('aria-describedby')).toBe('nucleus-lookup-a-error')
expect(city.getAttribute('aria-describedby')).toBe('nucleus-lookup-b-error')
expect(screen.getByText('Território inválido.').id).toBe('nucleus-lookup-a-error')
@ -167,9 +162,7 @@ describe('campaign nucleus UI contracts', () => {
fireEvent.change(region, { target: { value: 'Território inventado' } })
fireEvent.blur(region)
expect(region.value).toBe('')
expect(
screen.getByText('Selecione um território de identidade válido da Bahia.'),
).toBeTruthy()
expect(screen.getByText('Selecione um território de identidade válido da Bahia.')).toBeTruthy()
fireEvent.change(city, { target: { value: 'Município inventado' } })
fireEvent.blur(city)
@ -239,7 +232,9 @@ describe('campaign nucleus UI contracts', () => {
})
it('removes a city chip and re-derives the territory from the remaining cities', async () => {
render(createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra', 'Salvador'] } }))
render(
createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra', 'Salvador'] } }),
)
expect(screen.getByText('Chapada Diamantina')).toBeTruthy()
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
@ -579,6 +574,7 @@ describe('campaign nucleus UI contracts', () => {
confirmedVoteEstimate: 1200,
proposedVoteEstimate: null,
lastUpdateAt: null,
priority: 'normal',
},
],
}),
@ -611,9 +607,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('shows no available coordinator only for an empty eligible list', () => {
const emptyHtml = renderToStaticMarkup(
createElement(NucleusFormFields, { coordinators: [] }),
)
const emptyHtml = renderToStaticMarkup(createElement(NucleusFormFields, { coordinators: [] }))
const eligibleHtml = renderToStaticMarkup(
createElement(NucleusFormFields, {
coordinators: [{ id: 1, name: 'Coordenação Geral', isCurrent: true }] as never,
@ -642,6 +636,8 @@ describe('campaign nucleus UI contracts', () => {
sectorKind: null,
tseZones: [],
ticketAlliance: null,
voteGoals: { good: null, regular: null, minimum: null },
priority: 'normal',
},
}),
)
@ -674,5 +670,4 @@ describe('campaign nucleus UI contracts', () => {
expect(html).toContain('id="name-error"')
expect(html).toContain('aria-describedby="tseZones-error"')
})
})