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:
parent
456b5d8a4e
commit
e5e051d175
31 changed files with 835 additions and 117 deletions
114
docs/plans/escala-dry-pos-e1.md
Normal file
114
docs/plans/escala-dry-pos-e1.md
Normal 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
|
||||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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ê)' : ''}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const intelligenceProps = {
|
|||
risks: [],
|
||||
voterProfiles: [],
|
||||
ticketAlliance: null,
|
||||
dobradinhaNotes: null,
|
||||
nextSteps: null,
|
||||
},
|
||||
primaryContact: null,
|
||||
searchPrimaryContacts: async () => ({ current: null, options: [] }),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue