B21: cria página própria dos territórios

Leva a leitura dos 27 Territórios de Identidade para o sistema de listas com estado canônico na URL e remove o painel duplicado do Início.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 21:50:24 -03:00
parent ac671f4bc0
commit a9e1900290
30 changed files with 1698 additions and 579 deletions

View file

@ -10,6 +10,7 @@ alwaysApply: false
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=<TI>`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).

View file

@ -0,0 +1,61 @@
---
target: src/app/(campaign)/campanha/(app)/territorios/page.tsx
total_score: 25
p0_count: 0
p1_count: 2
timestamp: 2026-07-25T23-12-35Z
slug: src-app-campaign-campanha-app-territorios-page-tsx
---
Method: dual-agent (A: 216f0e46-ffcc-4caf-8c2e-947cb6fe8d8c · B: 7e537205-ca99-4e97-ac92-1742875a045b)
## Design Health Score
| # | Heuristic | Score | Key issue |
| --------- | ------------------------------- | --------: | --------------------------------------------------- |
| 1 | Visibility of system status | 3 | Pending region is honest and announced |
| 2 | Match system / real world | 2 | Metric labels need explicit semantics |
| 3 | User control and freedom | 3 | Clear search/filter recovery |
| 4 | Consistency and standards | 3 | Reuses the campaign list system |
| 5 | Error prevention | 2 | Mobile comparison can lose row context |
| 6 | Recognition rather than recall | 2 | Mobile selected territory was hidden behind a count |
| 7 | Flexibility and efficiency | 3 | URL-backed sort/filter supports power use |
| 8 | Aesthetic and minimalist design | 3 | Sober field-desk visual language |
| 9 | Error recovery | 3 | Empty state offers direct recovery |
| 10 | Help and documentation | 1 | Intelligence metrics lack contextual definitions |
| **Total** | | **25/40** | **Acceptable** |
## Anti-patterns verdict
No classic AI slop. The restrained, flat field-desk styling is consistent with Teqo. The deterministic scan returned zero findings. Browser evidence found one functional gap that the detector could not see: on mobile, the empty state was centered across the full table width and started outside the visible horizontal viewport.
## What's working
- URL-backed filters and sorting make the view shareable and recoverable.
- Pending feedback dims the result region and announces updates without blocking the controls.
- The table reuses the campaign list system and keeps the data density appropriate for staff.
## Priority issues
- **P1 — Mobile row context and empty recovery:** the horizontal table is an approved product decision, but an 888–974 px table in a 356 px viewport needs a sticky territory column, and the empty recovery must remain in the initial viewport.
- **P1 — Ambiguous metric labels:** “% da votação”, “Estimativa 2026” and “Com assessor” hide denominator, scenario and coverage semantics.
- **P2 — Mobile active-filter recall:** a selected territory was reduced to “1 selecionado(s)” while the active summary was desktop-only.
- **P2 — Historical disclosure:** the 2022 value lacked a disclosure signifier and its tap target was below 44 px.
- **P2 — Action promise mismatch:** the intro promised allocation decisions while the current metrics support regional comparison.
## Persona red flags
- **Alex (power user):** can sort and share URLs quickly, but this slice deliberately does not yet include E12 regional deficit/LQ metrics.
- **Sam (keyboard/screen reader):** horizontal reflow is demanding; the historical disclosure and parent/sub-row relationship need explicit cues.
- **Casey (mobile field user):** selected filter names and row context must survive horizontal movement.
## Minor observations
- The generic campaign loading shell is broader than this list.
- The footer confirms quantity but intentionally avoids inventing a recommendation.
- The detector returned no automated rule findings; the material issues were interaction and semantics.
## Questions
- Which three regional metrics should remain visible without horizontal movement once E12 lands?
- Should Territórios become a mobile bottom-nav destination only after it carries actionable regional intelligence?

View file

@ -66,7 +66,9 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, planos-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActionPlanOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-25):** **B21 Página dos Territórios de Identidade** — nova rota staff-only `/campanha/territorios` com os 27 TIs no sistema de listas: sort/filtros/busca canônicos na URL, Metropolitano decomposto em sub-linhas, primeira coluna sticky no mobile e linha → `/campanha/municipios?region=<TI>`. O chrome rico agora é compartilhado por `CampaignSortableHead`/`CampaignHeaderFilterPopover`; wrappers de municípios mantêm a política de domínio. O painel de TI e `territorySlot` foram removidos do Início. Sem migration/collection/action. Plano: [`docs/plans/pagina-territorios-identidade.md`](docs/plans/pagina-territorios-identidade.md).
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).

View file

@ -3,10 +3,12 @@
Status: rascunho
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B33)
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — ver Dependências)
Appetite: ~0,5–0,75 dia eng se `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existirem (B29 ou B21 já entregues); ~1–1,5 dia eng se este item chegar primeiro e precisar fazer a extração
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado entregue pelo **B21 ✓**
Appetite: ~0,5–0,75 dia eng; `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existem
Responsável: —
**Atualização B21 (2026-07-25):** a corrida de ordem de chegada foi encerrada. B33 não extrai nem duplica chrome; implementa somente `StateDeputySortableHead`/`StateDeputyHeaderFilter` e seus módulos de URL/filtros.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`) e o par compartilhado que **B29** promove para `shared/`.

View file

@ -4,9 +4,11 @@ Status: rascunho
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B29)
Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico de `/campanha/municipios` (B15 ✓ + B16 ✓)
Appetite: ~1,5–2 dias eng; sem migration — módulo de URL + filtros no loader + duas colunas + extração do par head/filtro para `shared/` (com municípios migrado junto)
Appetite: ~1–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas; o chrome compartilhado já foi entregue pelo B21
Responsável: —
**Atualização B21 (2026-07-25):** a extração deixou de fazer parte do appetite deste item. `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` já existem, e os wrappers de municípios já foram migrados. B29 implementa apenas os wrappers/política de liderança e o contrato de URL/filtros próprios.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`).

View file

@ -1,12 +1,21 @@
# B21 — Página própria dos Territórios de Identidade (overview + tabela)
Status: rascunho
Status: ✓ entregue em 2026-07-25
Atualizado em: 2026-07-25
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B21; conteúdo herdado de E17 ✓ e estendido por E12)
Impeccable: C — rota nova `/campanha/territorios`, mas com encaixe direto no sistema de listas existente (`CampaignPageShell` + `CampaignTable` + header sort/filtro do B15/B16)
Appetite: ~1–1,5 dia eng; sem migration, sem collection, sem server action — rota + módulo de URL + definições de coluna + entrada no sidebar
Responsável: —
## As built (2026-07-25)
- `/campanha/territorios` é RSC staff-only, com busca, filtros por TI/assessoria, sort, canonicalização e pending pela URL; `leader` redireciona para `/campanha`.
- O contrato compartilhado `resolveListUrl` exige `page`; como esta lista fixa de 27 linhas não pagina, `resolveTerritoryListUrl` usa `inspectRawListParams` e não inventa `page: 1`.
- `CampaignTable` renderiza linhas planas; `flattenTerritoryRows` transforma pai/sub-linhas num union e mantém as duas decomposições do Metropolitano adjacentes ao pai.
- `CampaignSearchForm` descartaria sort/filtros; `TerritoryFilters` segue o padrão de `MunicipalityFilters`, preservando o estado e oferecendo controles mobile.
- A extração disparada por B29/B33 foi antecipada: `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` contêm só chrome/a11y; wrappers de município e território mantêm política de domínio.
- Por decisão desta sessão, o painel de TI foi **removido** do Início, junto com `territorySlot` e `TerritoryOverviewTable`; não há variante compacta.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2 e 6; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`src/components/campaign/shared/CampaignTable.tsx`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListPendingBoundary`) · painel entregue no Início (`TerritoryOverviewTable`).
@ -48,7 +57,7 @@ O pedido (2026-07-25) é fechar essa distância: uma **página própria** para o
- **Filtro no header** (Popover, padrão B16) no conjunto mínimo que decide algo com 27 linhas: Território (multi-seleção) e Cobertura (`com_assessor` / `sem_assessor`), mais busca por nome; chips de filtro ativo + "Limpar" pelos shells compartilhados.
- **Metropolitano decomposto** preservado (Salvador 19 zonas × Demais RMS) — salvaguarda permanente do relatório; sub-linhas nunca são reordenadas independentemente.
- **Linha → lista filtrada** `/campanha/municipios?region=<TI>` (comportamento já entregue no E17).
- **Uma única implementação da leitura por TI:** o painel do Início passa a consumir as mesmas definições de coluna (subconjunto compacto) e ganha um link "Ver todos os territórios" para a página. Sem segunda tabela divergente.
- **Uma única implementação da leitura por TI:** a página dedicada substitui o painel do Início; a tabela antiga e seu slot foram removidos para não manter duas superfícies divergentes.
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; leitura agregada com `overrideAccess: true` (precedente E17), nunca PII por município.
## Decisões travadas
@ -63,7 +72,7 @@ O pedido (2026-07-25) é fechar essa distância: uma **página própria** para o
## Questões em aberto
- **O painel do Início continua?** **Opções:** (A) continua completo + link; (B) vira versão compacta (Território · % da votação · Com assessor) com "Ver todos"; (C) sai do Início. **Recomendação: B** — o Início volta a ser resumo e a página vira o lugar de trabalhar, sem perder o pedido do candidato (a leitura regional segue visível na home). Reaproveita as mesmas definições de coluna via subconjunto de `id`s. _(assumido — validar na primeira demo.)_
- **O painel do Início continua?** **Resolvido em 2026-07-25: C — saiu do Início.** A página dedicada é o lugar de comparar TIs; a remoção também eliminou `territorySlot` e `TerritoryOverviewTable`.
- **Entrada no sidebar: item próprio ou 2º nível sob Municípios?** **Opções:** item em `staffNav` logo abaixo de Municípios | submenu de Municípios (o padrão que o B18 planeja para filtros salvos). **Recomendação:** item próprio em `staffNav` (`MapIcon`), **fora** do `getCampaignBottomNav` (o bottom bar corta em 5 e Territórios não desloca Planos). O submenu do B18 é para estados salvos da mesma lista, não para outra entidade.
- **Filtro de "peso" (ex.: só TIs acima de X% da votação)?** **Recomendação:** não na v1 — com 27 linhas, ordenar por % resolve; um filtro numérico é controle a mais sem decisão nova. Reavaliar se a mesa pedir corte fixo.
@ -124,6 +133,7 @@ Componentes:
## Adiado com gatilho
- **Head/URL genéricos em `shared/`.** Gatilho **disparado em 2026-07-25**: o 3º call site é o **B29** (sort + filtro no header de `/campanha/liderancas` — [plano](ordenacao-filtros-lista-liderancas.md)), que **assume** a extração do par head/filtro para `shared/` e a migração de municípios. Se B21 entrar primeiro, duplica o head uma vez como descrito acima e o B29 migra os três; se B29 entrar primeiro, esta página já nasce consumindo o compartilhado. Os **módulos de URL** seguem um por domínio em qualquer ordem.
- **Agregação SQL específica para a página.** O rollup atual lê 435 municípios e agrega os pledges vivos em memória a cada navegação de sort/filtro; isso é deliberado enquanto a cardinalidade é pequena e não existe invalidação segura para cache cross-request. Gatilho: p95 medido da rota ultrapassar 500 ms ou o volume de pledges tornar essa leitura material; então projetar somente os totais por cenário necessários no banco, sem cache de campanha sem invalidação.
- **Filtros adicionais no header** (tendência agregada, faixa de peso). Gatilho: a mesa pedir um corte que a ordenação não resolve em sessão real.
- **Coluna de cobertura de meta por TI.** Gatilho: **E12** entregue com a regra "razão dos agregados + decomposição obrigatória".

View file

@ -91,7 +91,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08
- **B14** município mais próximo (geolocalização → atalho no Início staff) · pede permissão **1× por sessão** se ainda não concedida; matching client-side sobre centroides IBGE; Salvador multi-zona → lista filtrada até B8 F2 · ~1d · Janela 1–2 · sem deps duras · cortável · [plano](plans/municipio-mais-proximo.md)
- ~~**B20** Municípios prioritários no Início~~ — **entregue 2026-07-25** (`pickDashboardPriorityMunicipalities` + amostra 8 por déficit `central`; Badge com total; “Ver todas” → `?priority=alta`; `list-none` na strip) · [plano](plans/municipios-prioritarios-inicio.md)
- **B21** página própria dos Territórios de Identidade — rota staff `/campanha/territorios` com overview + tabela dos 27 TIs no sistema de listas (colunas como dado em `CampaignTable`, sort no header via URL B15, filtro em Popover B16, busca/chips/footer compartilhados); reusa o rollup do **E17 ✓** (`territoryOverview.ts`/`loadTerritoryOverview.ts`), Metropolitano decomposto, linha → `?region=<TI>`; painel do Início vira variante compacta com “Ver todos” (mesmas definições de coluna); **E12** passa a pousar suas métricas aqui em vez de abrir tela nova; sem migration/collection/action · ~1–1,5d · Janela 1–2 · Impeccable C · soft: E12/B17/B13 · cortável · [plano](plans/pagina-territorios-identidade.md)
- ~~**B21** página própria dos Territórios de Identidade~~ — **entregue 2026-07-25** (`/campanha/territorios` staff-only; 27 TIs + decomposição do Metropolitano; sort/filtros/busca canônicos na URL; drill para municípios; chrome extraído em `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`; painel de TI removido do Início por decisão da sessão) · sem migration/collection/action · [plano](plans/pagina-territorios-identidade.md)
- **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios.
- ~~**B15** ordenar lista de municípios pelo header da coluna~~ — **entregue 2026-07-24** (`?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado; **A11** consome o contrato (key `votos`)) · [plano](plans/ordenacao-colunas-lista-municipios.md)
- ~~**B16** filtros no header das colunas da lista de municípios~~ — **entregue 2026-07-24** (funil+Popover no `TableHead` ao lado do sort B15; barra slim busca+resumo+Limpar; mobile selects empilhados; optimistic + busca no Popover de TI) · [plano](plans/filtros-no-header-lista-municipios.md)
- **B22** explicação por coluna no header das listas — pedido de produto (2026-07-25): tooltip no hover de cada header de `/campanha/municipios` dizendo o que a coluna mede, **implementado no componente de header** para reuso. Hoje a capacidade existe pela metade (prop `tooltip` do `MunicipalitySortableHead`, usada em 3 de 9 colunas; primitivo preso ao domínio `municipality`; `CampaignTableHead` sem prop nenhuma): promover `MunicipalityHoverTooltip`→`shared/CampaignHoverTooltip`, prop `description` nos dois heads, copy das colunas de municípios com `oneLiner` do **E18 ✓** como fonte única nas métricas do E8; sem migration/action; dá evidência datada para o débito **O3**/glossário inline do R6 · ~0,5d · Janela 1–2 · Impeccable B · soft: B15 ✓/B16 ✓/E18 ✓ · cortável · [plano](plans/explicacao-colunas-header-listas.md)

View file

@ -7,15 +7,12 @@ import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashb
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { TerritoryOverviewTable } from '@/components/campaign/municipality/TerritoryOverviewTable'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/shell/CampaignPageShell'
import type { CampaignUser } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
export const dynamic = 'force-dynamic'
@ -66,20 +63,6 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
</Suspense>
) : null
}
territorySlot={
isStaff ? (
<Suspense
fallback={
<div
aria-hidden="true"
className="h-[280px] w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<TerritoryOverviewSection payload={payload} />
</Suspense>
) : null
}
/>
)
}
@ -105,18 +88,3 @@ const DashboardMapSection = async ({
</MunicipalityEstimateScenarioProvider>
)
}
const TerritoryOverviewSection = async ({ payload }: { payload: Payload }) => {
const rows = await loadTerritoryOverview(payload)
return (
<section
aria-labelledby="territory-overview-title"
className={`rounded-xl border p-4 ${campaignPrioritySurfaceClassName}`}
>
<h2 id="territory-overview-title" className="mb-3 text-base font-medium">
Territórios de Identidade
</h2>
<TerritoryOverviewTable rows={rows} />
</section>
)
}

View file

@ -0,0 +1,68 @@
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
import { TerritoryList } from '@/components/campaign/municipality/TerritoryList'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
import { resolveTerritoryListSort, resolveTerritoryListUrl } from '@/utilities/territoryListUrl'
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territoryOverview'
type TerritoriesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function TerritoriesPage({ searchParams }: TerritoriesPageProps) {
const rawSearchParams = await searchParams
const resolvedUrl = resolveTerritoryListUrl(rawSearchParams)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
if (!user) return null
if (isCampaignLeader(user)) redirect('/campanha')
const allRows = await loadTerritoryOverview(payload)
const { state } = resolvedUrl
const { sort, dir } = resolveTerritoryListSort(state)
const rows = sortTerritoryRows(filterTerritoryRows(allRows, state), sort, dir)
const regionOptions = allRows
.map((row) => ({ value: row.region, label: row.region }))
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
return (
<CampaignPageShell>
<header className="flex max-w-prose flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-balance">
Territórios de Identidade
</h1>
<p className="text-muted-foreground text-pretty">
Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um
território para ver seus municípios.
</p>
</header>
<CampaignListPendingBoundary>
<TerritoryFilters state={state} regionOptions={regionOptions} />
<CampaignListResults>
<TerritoryList rows={rows} state={state} regionOptions={regionOptions} />
<CampaignListFooter
totalDocs={rows.length}
singular="território encontrado"
plural="territórios encontrados"
page={1}
totalPages={1}
hrefForPage={() => resolvedUrl.href}
/>
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -30,14 +30,11 @@ export const CampaignDashboard = ({
view,
userName,
mapSlot = null,
territorySlot = null,
}: {
view: StaffDashboardView
userName: string
/** Server-streamed map section (composition keeps this component map-agnostic). */
mapSlot?: ReactNode
/** Server-streamed Territórios de Identidade comparative table (E17). */
territorySlot?: ReactNode
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
@ -85,8 +82,6 @@ export const CampaignDashboard = ({
/>
</section>
{territorySlot}
<div className="grid gap-6 lg:grid-cols-2">
<section
aria-labelledby="dashboard-missing-title"

View file

@ -4,6 +4,7 @@ import { useCampaignListPending } from '@/components/campaign/shared/CampaignLis
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
@ -33,44 +34,6 @@ import {
const SEARCH_DEBOUNCE_MS = 1000
/** Mobile stand-in for a header multi-select: pick to add, pick again to remove. */
const MobileMultiFilterField = ({
id,
label,
emptyLabel,
options,
selected,
onToggle,
}: {
id: string
label: string
emptyLabel: string
options: MunicipalityFilterOption[]
selected: string[]
onToggle: (value: string) => void
}) => (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<NativeSelect
id={id}
value=""
onChange={(event) => {
if (event.target.value) onToggle(event.target.value)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
</NativeSelectOption>
{options.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)
type MunicipalityFiltersProps = {
state: MunicipalityListState
showStaffFilters: boolean
@ -237,7 +200,7 @@ export const MunicipalityFilters = ({
</Field>
) : null}
{regionFilterOptions.length ? (
<MobileMultiFilterField
<CampaignMobileMultiFilterField
id="municipality-filter-region"
label="Território"
emptyLabel="Todos"
@ -295,7 +258,7 @@ export const MunicipalityFilters = ({
)
})}
{showStaffFilters ? (
<MobileMultiFilterField
<CampaignMobileMultiFilterField
id="municipality-filter-trend"
label="Tendência"
emptyLabel="Todas"
@ -313,7 +276,7 @@ export const MunicipalityFilters = ({
/>
) : null}
{showStaffFilters && advisorFilterOptions.length ? (
<MobileMultiFilterField
<CampaignMobileMultiFilterField
id="municipality-filter-advisor"
label="Assessores"
emptyLabel="Todos"

View file

@ -1,14 +1,11 @@
'use client'
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
import { useMemo, useOptimistic, useState, type ReactNode } from 'react'
import { useOptimistic } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { Checkbox } from '@/components/ui/Checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import { municipalityPriorityLabels } from '@/utilities/municipalityLabels'
import {
applyMunicipalityKindFilter,
@ -44,9 +41,6 @@ type MunicipalityHeaderFilterProps = {
showPriorityFilter?: boolean
}
/** Above this many options the popover gets a search box. */
const SEARCHABLE_OPTION_THRESHOLD = 8
const multiParamByFilter = {
name: 'slug',
region: 'region',
@ -56,54 +50,12 @@ const multiParamByFilter = {
coverage: null,
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>
const FilterOptionLink = ({
href,
selected,
checkbox = false,
onChoose,
children,
}: {
href: string
selected: boolean
checkbox?: boolean
onChoose: () => void
children: ReactNode
}) => (
<CampaignTransitionAnchor
href={href}
replace
scroll={false}
aria-current={selected ? 'true' : undefined}
className={cn(
'flex min-h-11 items-center rounded-md px-2 text-sm whitespace-normal hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
checkbox ? 'gap-2' : selected && 'bg-muted font-medium',
)}
onNavigate={onChoose}
>
{checkbox ? (
<>
<Checkbox
checked={selected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
<span className={cn(selected && 'font-medium')}>{children}</span>
</>
) : (
children
)}
</CampaignTransitionAnchor>
)
export const MunicipalityHeaderFilter = ({
state,
filterParam,
options: optionsInput,
showPriorityFilter = false,
}: MunicipalityHeaderFilterProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
/**
* Optimistic selection: set inside the navigation transition (see
* `CampaignTransitionAnchor.onNavigate`), so React shows the clicked state
@ -114,28 +66,15 @@ export const MunicipalityHeaderFilter = ({
const definition = getMunicipalityFilterDefinition(filterParam)
const definitionOptions = definition.options
const options = useMemo(() => {
if (!optionsInput) return definitionOptions ?? []
return typeof optionsInput[0] === 'string' || optionsInput.length === 0
const options = !optionsInput
? (definitionOptions ?? [])
: typeof optionsInput[0] === 'string' || optionsInput.length === 0
? municipalityFilterOptionsForSlugs(optionsInput as readonly string[])
: (optionsInput as MunicipalityFilterOption[])
}, [optionsInput, definitionOptions])
const isActive = isMunicipalityColumnFilterActive(viewState, filterParam)
const searchable = options.length >= SEARCHABLE_OPTION_THRESHOLD
// Normalizing 435 labels per keystroke is the cost to avoid here, not the filter itself.
const searchIndex = useMemo(
() => options.map((option) => ({ option, haystack: normalizeSearchPhrase(option.label) })),
[options],
)
const needle = normalizeSearchPhrase(query)
const visibleOptions = needle
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.option)
: options
const commit = (next: MunicipalityListState) => {
setOptimisticState(next)
if (definition.selection !== 'multi') setOpen(false)
}
const multiParam = multiParamByFilter[filterParam]
@ -173,143 +112,72 @@ export const MunicipalityHeaderFilter = ({
: viewState
const hasSelection = selectedMulti.length > 0 || exclusiveRows.some((row) => row.selected)
const showClear = hasSelection
const sharedExclusiveRows: CampaignHeaderFilterRow[] = exclusiveRows.map((row) => ({
value: row.label,
label: row.label,
href: buildMunicipalityFilterHref(row.next),
selected: row.selected,
checkbox: true,
onChoose: () => commit(row.next),
}))
const singleRows: CampaignHeaderFilterRow[] =
definition.selection === 'single'
? [
...(definition.allLabel
? [
{
value: '',
label: definition.allLabel,
href: buildMunicipalityFilterHref(
applyMunicipalityKindFilter(viewState, undefined),
),
selected: !getMunicipalitySingleFilterValue(viewState, 'kind'),
onChoose: () => commit(applyMunicipalityKindFilter(viewState, undefined)),
},
]
: []),
...options.map((option) => {
const next = applyMunicipalityKindFilter(viewState, option.value)
return {
value: option.value,
label: option.label,
href: buildMunicipalityFilterHref(next),
selected: getMunicipalitySingleFilterValue(viewState, 'kind') === option.value,
onChoose: () => commit(next),
}
}),
]
: []
const multiRows: CampaignHeaderFilterRow[] = multiParam
? options.map((option) => ({
value: option.value,
label: option.label,
href: buildMunicipalityFilterOptionHref(viewState, multiParam, option.value),
selected: selectedMulti.includes(option.value),
checkbox: true,
onChoose: () =>
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value)),
}))
: []
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-11 w-7 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
aria-label={
isActive
? `Filtrar ${definition.label}: ativo. Alterar filtro`
: `Filtrar por ${definition.label}`
}
>
{isActive ? (
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-2">
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<p
id={`municipality-filter-heading-${filterParam}`}
className="text-xs font-medium text-muted-foreground"
>
{definition.label}
</p>
{showClear ? (
<CampaignTransitionAnchor
href={buildMunicipalityFilterHref(clearedState)}
replace
scroll={false}
className="text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onNavigate={() => commit(clearedState)}
>
Limpar
</CampaignTransitionAnchor>
) : null}
</div>
{exclusiveRows.length ? (
<div className="mb-1 flex flex-col gap-0.5 border-b border-border px-1 pb-1">
{exclusiveRows.map((row) => (
<FilterOptionLink
key={row.label}
href={buildMunicipalityFilterHref(row.next)}
selected={row.selected}
checkbox
onChoose={() => commit(row.next)}
>
{row.label}
</FilterOptionLink>
))}
</div>
) : null}
{searchable ? (
<div className="px-1 pb-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar…"
aria-label={`Buscar em ${definition.label}`}
className="min-h-11"
autoFocus
/>
</div>
) : null}
<nav
aria-labelledby={`municipality-filter-heading-${filterParam}`}
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
>
{definition.selection === 'single' && definition.allLabel ? (
<FilterOptionLink
href={buildMunicipalityFilterHref(applyMunicipalityKindFilter(viewState, undefined))}
selected={!getMunicipalitySingleFilterValue(viewState, 'kind')}
onChoose={() => commit(applyMunicipalityKindFilter(viewState, undefined))}
>
{definition.allLabel}
</FilterOptionLink>
) : null}
{/* Tipo is the only single-select column; com/sem assessor rides along
as an exclusive row of the Assessores column. */}
{definition.selection === 'single'
? visibleOptions.map((option) => {
const next = applyMunicipalityKindFilter(viewState, option.value)
return (
<FilterOptionLink
key={option.value}
href={buildMunicipalityFilterHref(next)}
selected={getMunicipalitySingleFilterValue(viewState, 'kind') === option.value}
onChoose={() => commit(next)}
>
{option.label}
</FilterOptionLink>
)
})
: null}
{/* Option hrefs ride the cheap canonical-toggle serializer; the full
toggle (state for the optimistic commit) runs only on click. */}
{multiParam
? visibleOptions.map((option) => (
<FilterOptionLink
key={option.value}
href={buildMunicipalityFilterOptionHref(viewState, multiParam, option.value)}
selected={selectedMulti.includes(option.value)}
checkbox
onChoose={() =>
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value))
}
>
{option.label}
</FilterOptionLink>
))
: null}
{visibleOptions.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{needle ? 'Nenhum resultado encontrado.' : 'Nenhuma opção no escopo filtrado.'}
</p>
) : null}
</nav>
</PopoverContent>
</Popover>
<CampaignHeaderFilterPopover
id={`municipality-filter-${filterParam}`}
label={definition.label}
active={isActive}
closeOnChoose={definition.selection !== 'multi'}
exclusiveRows={sharedExclusiveRows}
optionRows={definition.selection === 'single' ? singleRows : multiRows}
clear={
hasSelection
? {
href: buildMunicipalityFilterHref(clearedState),
onChoose: () => commit(clearedState),
}
: undefined
}
/>
)
}

View file

@ -1,13 +1,10 @@
'use client'
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter'
import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { TableHead } from '@/components/ui/Table'
import { cn } from '@/lib/utils'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import type {
MunicipalityFilterOption,
MunicipalityFilterParam,
@ -49,89 +46,44 @@ export const MunicipalitySortableHead = ({
const { sort: activeSort, dir } = resolveMunicipalityListSort(state)
const active = activeSort === sortKey
const label = children ?? municipalityListSortLabels[sortKey]
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
const nextDir = active
? dir === 'asc'
? 'desc'
: 'asc'
: defaultMunicipalityListSortDir(sortKey)
const nextDirLabel = nextDir === 'asc' ? 'crescente' : 'decrescente'
const sortControl = (
<CampaignTransitionAnchor
href={href}
replace
scroll={false}
className={cn(
'group inline-flex min-h-11 items-center gap-1 rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'hover:text-foreground',
active ? 'font-medium' : 'font-normal',
align === 'right' && !filterParam && 'w-full justify-end',
align === 'center' && !filterParam && 'w-full justify-center',
)}
aria-label={
active
? `Ordenar por ${municipalityListSortLabels[sortKey]}, inverter para ${nextDirLabel}`
: `Ordenar por ${municipalityListSortLabels[sortKey]} (${nextDirLabel})`
}
>
<span>{label}</span>
{active ? (
dir === 'asc' ? (
<ChevronUpIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
)
) : (
<ChevronsUpDownIcon
className="size-3.5 shrink-0 opacity-60 group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
/>
)}
</CampaignTransitionAnchor>
)
const labeledSort = tooltip ? (
<MunicipalityHoverTooltip
content={tooltip}
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
>
{sortControl}
</MunicipalityHoverTooltip>
) : (
sortControl
)
return (
<TableHead
aria-sort={ariaSort}
className={cn(
'text-muted-foreground',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
align === 'left' && 'text-left',
className,
)}
>
{filterParam ? (
<div
className={cn(
'flex items-center',
align === 'right' && 'justify-end',
align === 'center' && 'justify-center',
)}
>
{labeledSort}
<CampaignSortableHead
active={active}
align={align}
className={className}
dir={dir}
href={href}
nextDir={nextDir}
sortLabel={municipalityListSortLabels[sortKey]}
filter={
filterParam ? (
<MunicipalityHeaderFilter
state={state}
filterParam={filterParam}
options={filterOptions}
showPriorityFilter={showPriorityFilter}
/>
</div>
) : (
labeledSort
)}
</TableHead>
) : null
}
wrapSortControl={
tooltip
? (control) => (
<MunicipalityHoverTooltip
content={tooltip}
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
>
{control}
</MunicipalityHoverTooltip>
)
: undefined
}
>
{label}
</CampaignSortableHead>
)
}

View file

@ -0,0 +1,193 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
clearTerritoryListFilters,
formatTerritoryActiveFiltersSummary,
territoryCoverageLabels,
toggleTerritoryRegionFilter,
type TerritoryFilterOption,
} from '@/utilities/territoryListFilters'
import {
buildTerritoryListHref,
parseTerritoryListParams,
parseTerritorySortValue,
resolveTerritoryListSort,
serializeTerritorySortValue,
territoryListSortOptions,
territoryListStateToRawParams,
type TerritoryListState,
} from '@/utilities/territoryListUrl'
const SEARCH_DEBOUNCE_MS = 1000
export const TerritoryFilters = ({
state,
regionOptions,
}: {
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
}) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const sharedPending = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = sharedPending?.isPending ?? isLocalPending
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { sort, dir } = resolveTerritoryListSort(state)
const activeSummary = formatTerritoryActiveFiltersSummary({
...state,
q: normalizedText(search) || state.q,
})
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
[],
)
const clearDebounce = () => {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
}
const navigateTo = (next: TerritoryListState) => {
clearDebounce()
const canonical = parseTerritoryListParams(
territoryListStateToRawParams({ ...next, q: normalizedText(next.q) }),
)
if (buildTerritoryListHref(canonical) === buildTerritoryListHref(state)) return
startTransition(() => {
router.replace(buildTerritoryListHref(canonical), { scroll: false })
})
}
const scheduleSearch = (value: string) => {
clearDebounce()
if (normalizedText(value) === state.q) return
debounceRef.current = setTimeout(() => {
debounceRef.current = null
navigateTo({ ...state, q: value })
}, SEARCH_DEBOUNCE_MS)
}
return (
<form
role="search"
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateTo({ ...state, q: search })
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="territory-search"
label="Buscar território"
placeholder="Buscar por território…"
value={search}
onChange={(event) => {
setSearch(event.target.value)
scheduleSearch(event.target.value)
}}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => {
clearDebounce()
setSearch('')
navigateTo(clearTerritoryListFilters(state))
}}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
<CampaignMobileMultiFilterField
id="territory-filter-region"
label="Território"
emptyLabel="Todos"
options={regionOptions}
selected={state.regions ?? []}
onToggle={(value) => navigateTo(toggleTerritoryRegionFilter(state, value))}
/>
<Field>
<FieldLabel htmlFor="territory-filter-coverage">Assessoria</FieldLabel>
<NativeSelect
id="territory-filter-coverage"
value={state.coverage ?? ''}
onChange={(event) =>
navigateTo({
...state,
coverage:
event.target.value === 'com_assessor' || event.target.value === 'sem_assessor'
? event.target.value
: undefined,
})
}
className="w-full [&_select]:h-11"
>
<NativeSelectOption value="">Todas</NativeSelectOption>
{Object.entries(territoryCoverageLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="territory-sort">Ordenar</FieldLabel>
<NativeSelect
id="territory-sort"
value={serializeTerritorySortValue(sort, dir)}
onChange={(event) => {
const parsed = parseTerritorySortValue(event.target.value)
if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir })
}}
className="w-full [&_select]:h-11"
>
{territoryListSortOptions.map((option) => (
<NativeSelectOption
key={serializeTerritorySortValue(option.key, option.dir)}
value={serializeTerritorySortValue(option.key, option.dir)}
>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
</form>
)
}

View file

@ -0,0 +1,85 @@
'use client'
import { useOptimistic } from 'react'
import {
CampaignHeaderFilterPopover,
type CampaignHeaderFilterRow,
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
import {
isTerritoryFilterActive,
territoryCoverageOptions,
toggleTerritoryCoverageFilter,
toggleTerritoryRegionFilter,
type TerritoryFilterOption,
} from '@/utilities/territoryListFilters'
import { buildTerritoryListHref, type TerritoryListState } from '@/utilities/territoryListUrl'
type TerritoryHeaderFilterProps =
| {
state: TerritoryListState
filterParam: 'region'
options: TerritoryFilterOption[]
}
| {
state: TerritoryListState
filterParam: 'coverage'
options?: never
}
export const TerritoryHeaderFilter = ({
state,
filterParam,
options,
}: TerritoryHeaderFilterProps) => {
const [viewState, setOptimisticState] = useOptimistic(state)
const active = isTerritoryFilterActive(viewState, filterParam)
const rows: CampaignHeaderFilterRow[] =
filterParam === 'region'
? options.map((option) => {
const next = toggleTerritoryRegionFilter(viewState, option.value)
return {
value: option.value,
label: option.label,
href: buildTerritoryListHref(next),
selected: Boolean(viewState.regions?.some((region) => region === option.value)),
checkbox: true,
onChoose: () => setOptimisticState(next),
}
})
: territoryCoverageOptions.map((option) => {
const next = toggleTerritoryCoverageFilter(viewState, option.value)
return {
value: option.value,
label: option.label,
href: buildTerritoryListHref(next),
selected: viewState.coverage === option.value,
checkbox: false,
onChoose: () => setOptimisticState(next),
}
})
const clearedState: TerritoryListState =
filterParam === 'region'
? { ...viewState, regions: undefined }
: { ...viewState, coverage: undefined }
return (
<CampaignHeaderFilterPopover
id={`territory-filter-${filterParam}`}
label={filterParam === 'region' ? 'Território' : 'Assessoria'}
active={active}
closeOnChoose={filterParam === 'coverage'}
optionRows={rows}
clear={
active
? {
href: buildTerritoryListHref(clearedState),
onChoose: () => setOptimisticState(clearedState),
}
: undefined
}
/>
)
}

View file

@ -0,0 +1,84 @@
import { SearchXIcon } from 'lucide-react'
import { territoryListColumns } from '@/components/campaign/municipality/TerritoryListColumns'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable } from '@/components/campaign/shared/CampaignTable'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { cn } from '@/lib/utils'
import {
clearTerritoryListFilters,
type TerritoryFilterOption,
} from '@/utilities/territoryListFilters'
import {
buildTerritoryListHref,
formatTerritoryListSortSummary,
resolveTerritoryListSort,
type TerritoryListState,
} from '@/utilities/territoryListUrl'
import { flattenTerritoryRows, type TerritoryOverviewRow } from '@/utilities/territoryOverview'
const TerritoryListEmptyState = ({ state }: { state: TerritoryListState }) => (
<Empty className="min-h-56 w-64 sm:w-80 md:w-auto">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum território encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros para comparar os territórios.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CampaignTransitionAnchor
href={buildTerritoryListHref(clearTerritoryListFilters(state))}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
>
Limpar busca e filtros
</CampaignTransitionAnchor>
</EmptyContent>
</Empty>
)
export const TerritoryList = ({
rows,
state,
regionOptions,
}: {
rows: TerritoryOverviewRow[]
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
}) => {
const { sort, dir } = resolveTerritoryListSort(state)
const sortSummary = formatTerritoryListSortSummary(sort, dir)
return (
<>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Comparação dos Territórios de Identidade. Leitura regional; a alocação é decidida por município.`}
className="overflow-visible"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
columns={territoryListColumns({ state, regionOptions })}
rows={flattenTerritoryRows(rows)}
rowKey={(row) =>
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
}
rowClassName={(row) => (row.variant === 'sub' ? 'bg-muted/30' : undefined)}
empty={<TerritoryListEmptyState state={state} />}
/>
</>
)
}

View file

@ -0,0 +1,149 @@
import { TerritorySortableHead } from '@/components/campaign/municipality/TerritorySortableHead'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { formatElectionNumber } from '@/lib/electionFormat'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import type { TerritoryListState } from '@/utilities/territoryListUrl'
import type { TerritoryTableRow } from '@/utilities/territoryOverview'
const percentFormatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%`
const YearSeriesCell = ({ votesByYear }: { votesByYear: Record<string, number> }) => {
const prior = [
{ year: '2014', value: votesByYear['2014'] ?? 0 },
{ year: '2018', value: votesByYear['2018'] ?? 0 },
]
return (
<details className="group inline-flex flex-row-reverse items-center gap-1">
<summary
className="cursor-pointer list-none tabular-nums"
aria-label={`Votos em 2022: ${formatElectionNumber(votesByYear['2022'] ?? 0)}. Abrir série de 2014 e 2018`}
>
{formatElectionNumber(votesByYear['2022'] ?? 0)}
</summary>
<span className="hidden whitespace-nowrap text-xs text-muted-foreground group-open:inline">
{prior.map(({ year, value }) => `${year}: ${formatElectionNumber(value)}`).join(' · ')}
</span>
</details>
)
}
const CoverageCell = ({
withAdvisorCount,
total,
muted,
}: {
withAdvisorCount: number
total: number
muted: boolean
}) => {
if (total === 0) return <span className="text-muted-foreground">—</span>
if (withAdvisorCount === 0) {
return <Badge variant="estimate-pending">0 de {total}</Badge>
}
return (
<span className={muted ? 'tabular-nums text-muted-foreground' : 'tabular-nums'}>
{withAdvisorCount} de {total}
</span>
)
}
export const territoryListColumns = ({
state,
regionOptions,
}: {
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
}): Array<CampaignTableColumn<TerritoryTableRow>> => [
{
id: 'region',
mandatory: true,
defaultVisible: true,
head: (
<TerritorySortableHead
state={state}
sortKey="region"
filterParam="region"
filterOptions={regionOptions}
className="sticky left-0 z-20 min-w-56 bg-background"
/>
),
cell: (row) =>
row.variant === 'parent' ? (
<CampaignTransitionAnchor
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
className="font-medium text-foreground hover:underline"
>
{row.region}
</CampaignTransitionAnchor>
) : (
<span className="pl-4 text-muted-foreground">{row.label}</span>
),
cellClassName: (row) =>
`sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`,
},
{
id: 'municipalities',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="municipalities" align="right" />,
cell: (row) => row.municipalityCount,
cellClassName: 'text-right tabular-nums',
},
{
id: 'votes2022',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="votes2022" align="right" />,
cell: (row) => <YearSeriesCell votesByYear={row.votesByYear} />,
cellClassName: 'text-right tabular-nums',
},
{
id: 'pct',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="pct" align="right" />,
cell: (row) => formatPercent(row.pctPropriaVotacao),
cellClassName: (row) =>
`text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`,
},
{
id: 'validVotes2022',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="validVotes2022" align="right" />,
cell: (row) => formatElectionNumber(row.validVotes2022),
cellClassName: 'text-right tabular-nums text-muted-foreground',
},
{
id: 'estimate2026',
defaultVisible: true,
head: <TerritorySortableHead state={state} sortKey="estimate2026" align="right" />,
cell: (row) => formatElectionNumber(row.estimate2026),
cellClassName: (row) =>
`text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`,
},
{
id: 'coverage',
defaultVisible: true,
head: (
<TerritorySortableHead
state={state}
sortKey="coverage"
align="right"
filterParam="coverage"
/>
),
cell: (row) => (
<CoverageCell
withAdvisorCount={row.withAdvisorCount}
total={row.municipalityCount}
muted={row.variant === 'sub'}
/>
),
cellClassName: 'text-right',
},
]

View file

@ -1,211 +0,0 @@
'use client'
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDownIcon } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
sortTerritoryRows,
type TerritoryOverviewRow,
type TerritorySortDir,
type TerritorySortKey,
} from '@/utilities/territoryOverview'
const percentFormatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%`
type ColumnDef = {
key: TerritorySortKey
label: string
align: 'left' | 'right'
}
const COLUMNS: ColumnDef[] = [
{ key: 'region', label: 'Território', align: 'left' },
{ key: 'municipalities', label: 'Municípios', align: 'right' },
{ key: 'votes2022', label: 'Votos 2022', align: 'right' },
{ key: 'pct', label: '% da votação', align: 'right' },
{ key: 'validVotes2022', label: 'Válidos 2022', align: 'right' },
{ key: 'estimate2026', label: 'Estimativa 2026', align: 'right' },
{ key: 'coverage', label: 'Com assessor', align: 'right' },
]
type TerritoryOverviewTableProps = {
rows: TerritoryOverviewRow[]
}
export const TerritoryOverviewTable = ({ rows }: TerritoryOverviewTableProps) => {
const [sortKey, setSortKey] = useState<TerritorySortKey>('pct')
const [sortDir, setSortDir] = useState<TerritorySortDir>('desc')
const sortedRows = useMemo(
() => sortTerritoryRows(rows, sortKey, sortDir),
[rows, sortKey, sortDir],
)
const toggleSort = (key: TerritorySortKey) => {
if (key === sortKey) {
setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir(key === 'region' ? 'asc' : 'desc')
}
}
const sortIcon = (key: TerritorySortKey) => {
if (key !== sortKey)
return <ChevronsUpDownIcon className="text-muted-foreground/50" aria-hidden="true" />
return sortDir === 'asc' ? (
<ChevronUpIcon aria-hidden="true" />
) : (
<ChevronDownIcon aria-hidden="true" />
)
}
return (
<div className="flex flex-col gap-3">
<Table>
<TableHeader>
<TableRow>
{COLUMNS.map((column) => {
const isActive = column.key === sortKey
return (
<TableHead
key={column.key}
aria-sort={isActive ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
className={column.align === 'right' ? 'text-right' : 'text-left'}
>
<button
type="button"
onClick={() => toggleSort(column.key)}
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground ${column.align === 'right' ? 'flex-row-reverse' : ''}`}
>
{sortIcon(column.key)}
{column.label}
</button>
</TableHead>
)
})}
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.map((row) => (
<TerritoryRow key={row.region} row={row} />
))}
</TableBody>
</Table>
<p className="text-xs text-muted-foreground">
Leitura regional; alocação decide-se por município. % da votação = votos do TI ÷ votos
estaduais (2022).
</p>
</div>
)
}
const TerritoryRow = ({ row }: { row: TerritoryOverviewRow }) => (
<>
<TableRow>
<TableCell>
<Link
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
className="font-medium text-foreground hover:underline"
>
{row.region}
</Link>
</TableCell>
<TableCell className="text-right tabular-nums">{row.municipalityCount}</TableCell>
<TableCell className="text-right tabular-nums">
<YearSeriesCell votesByYear={row.votesByYear} />
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatPercent(row.pctPropriaVotacao)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatElectionNumber(row.validVotes2022)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatElectionNumber(row.estimate2026)}
</TableCell>
<TableCell className="text-right">
<CoverageCell withAdvisorCount={row.withAdvisorCount} total={row.municipalityCount} />
</TableCell>
</TableRow>
{row.subRows?.map((subRow) => (
<TableRow key={`${row.region}-${subRow.label}`} className="bg-muted/30">
<TableCell className="pl-6 text-muted-foreground">{subRow.label}</TableCell>
<TableCell className="text-right tabular-nums">{subRow.municipalityCount}</TableCell>
<TableCell className="text-right tabular-nums">
<YearSeriesCell votesByYear={subRow.votesByYear} />
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatPercent(subRow.pctPropriaVotacao)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatElectionNumber(subRow.validVotes2022)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatElectionNumber(subRow.estimate2026)}
</TableCell>
<TableCell className="text-right">
<CoverageCell
withAdvisorCount={subRow.withAdvisorCount}
total={subRow.municipalityCount}
muted
/>
</TableCell>
</TableRow>
))}
</>
)
const YearSeriesCell = ({ votesByYear }: { votesByYear: Record<string, number> }) => {
const votes2022 = votesByYear['2022'] ?? 0
const prior = [
{ year: '2014', value: votesByYear['2014'] ?? 0 },
{ year: '2018', value: votesByYear['2018'] ?? 0 },
]
return (
<details className="group inline-flex flex-row-reverse items-center gap-1">
<summary className="cursor-pointer list-none tabular-nums" aria-label="Série 2014 e 2018">
{formatElectionNumber(votes2022)}
</summary>
<span className="hidden text-xs text-muted-foreground group-open:inline">
{prior.map((p) => `${p.year}: ${formatElectionNumber(p.value)}`).join(' · ')}
</span>
</details>
)
}
const CoverageCell = ({
withAdvisorCount,
total,
muted = false,
}: {
withAdvisorCount: number
total: number
muted?: boolean
}) => {
if (total === 0) return <span className="text-muted-foreground">—</span>
if (withAdvisorCount === 0) {
return <Badge variant="estimate-pending">0 de {total}</Badge>
}
return (
<span className={muted ? 'text-muted-foreground tabular-nums' : 'tabular-nums'}>
{withAdvisorCount} de {total}
</span>
)
}

View file

@ -0,0 +1,71 @@
'use client'
import type { ReactNode } from 'react'
import { TerritoryHeaderFilter } from '@/components/campaign/municipality/TerritoryHeaderFilter'
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
import {
buildTerritorySortHref,
defaultTerritoryListSortDir,
resolveTerritoryListSort,
territoryListSortLabels,
type TerritoryListSortKey,
type TerritoryListState,
} from '@/utilities/territoryListUrl'
type TerritorySortableHeadProps = {
state: TerritoryListState
sortKey: TerritoryListSortKey
children?: ReactNode
align?: 'left' | 'center' | 'right'
className?: string
} & (
| {
filterParam: 'region'
filterOptions: TerritoryFilterOption[]
}
| {
filterParam: 'coverage'
filterOptions?: never
}
| {
filterParam?: never
filterOptions?: never
}
)
export const TerritorySortableHead = ({
state,
sortKey,
children,
align = 'left',
className,
filterParam,
filterOptions,
}: TerritorySortableHeadProps) => {
const { sort: activeSort, dir } = resolveTerritoryListSort(state)
const active = activeSort === sortKey
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultTerritoryListSortDir(sortKey)
return (
<CampaignSortableHead
active={active}
align={align}
className={className}
dir={dir}
href={buildTerritorySortHref(state, sortKey)}
nextDir={nextDir}
sortLabel={territoryListSortLabels[sortKey]}
filter={
filterParam === 'region' ? (
<TerritoryHeaderFilter state={state} filterParam="region" options={filterOptions} />
) : filterParam === 'coverage' ? (
<TerritoryHeaderFilter state={state} filterParam="coverage" />
) : null
}
>
{children ?? territoryListSortLabels[sortKey]}
</CampaignSortableHead>
)
}

View file

@ -0,0 +1,173 @@
'use client'
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { Checkbox } from '@/components/ui/Checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { cn } from '@/lib/utils'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
export type CampaignHeaderFilterRow = {
value: string
label: string
href: string
selected: boolean
checkbox?: boolean
onChoose: () => void
}
type CampaignHeaderFilterPopoverProps = {
active: boolean
clear?: { href: string; onChoose: () => void }
closeOnChoose?: boolean
emptyLabel?: string
exclusiveRows?: CampaignHeaderFilterRow[]
id: string
label: string
optionRows: CampaignHeaderFilterRow[]
}
const SEARCHABLE_OPTION_THRESHOLD = 8
const FilterRow = ({ row, onChoose }: { row: CampaignHeaderFilterRow; onChoose: () => void }) => (
<CampaignTransitionAnchor
href={row.href}
replace
scroll={false}
aria-current={row.selected ? 'true' : undefined}
className={cn(
'flex min-h-11 items-center rounded-md px-2 text-sm whitespace-normal hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
row.checkbox ? 'gap-2' : row.selected && 'bg-muted font-medium',
)}
onNavigate={onChoose}
>
{row.checkbox ? (
<>
<Checkbox
checked={row.selected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
<span className={cn(row.selected && 'font-medium')}>{row.label}</span>
</>
) : (
row.label
)}
</CampaignTransitionAnchor>
)
export const CampaignHeaderFilterPopover = ({
active,
clear,
closeOnChoose = false,
emptyLabel = 'Nenhuma opção no escopo filtrado.',
exclusiveRows = [],
id,
label,
optionRows,
}: CampaignHeaderFilterPopoverProps) => {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchable = optionRows.length >= SEARCHABLE_OPTION_THRESHOLD
const searchIndex = useMemo(
() =>
optionRows.map((row) => ({
row,
haystack: normalizeSearchPhrase(row.label),
})),
[optionRows],
)
const needle = normalizeSearchPhrase(query)
const visibleRows = needle
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.row)
: optionRows
const choose = (row: CampaignHeaderFilterRow) => {
row.onChoose()
if (closeOnChoose) setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery('')
}}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex size-11 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
aria-label={active ? `Filtrar ${label}: ativo. Alterar filtro` : `Filtrar por ${label}`}
>
{active ? (
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-2">
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<p id={`${id}-heading`} className="text-xs font-medium text-muted-foreground">
{label}
</p>
{clear ? (
<CampaignTransitionAnchor
href={clear.href}
replace
scroll={false}
className="text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onNavigate={clear.onChoose}
>
Limpar
</CampaignTransitionAnchor>
) : null}
</div>
{exclusiveRows.length ? (
<div className="mb-1 flex flex-col gap-0.5 border-b border-border px-1 pb-1">
{exclusiveRows.map((row) => (
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
))}
</div>
) : null}
{searchable ? (
<div className="px-1 pb-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar…"
aria-label={`Buscar em ${label}`}
className="min-h-11"
autoFocus
/>
</div>
) : null}
<nav
aria-labelledby={`${id}-heading`}
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
>
{visibleRows.map((row) => (
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
))}
{visibleRows.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{needle ? 'Nenhum resultado encontrado.' : emptyLabel}
</p>
) : null}
</nav>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,46 @@
'use client'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
type CampaignMobileMultiFilterOption = {
value: string
label: string
}
export const CampaignMobileMultiFilterField = ({
id,
label,
emptyLabel,
options,
selected,
onToggle,
}: {
id: string
label: string
emptyLabel: string
options: CampaignMobileMultiFilterOption[]
selected: readonly string[]
onToggle: (value: string) => void
}) => (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<NativeSelect
id={id}
value=""
onChange={(event) => {
if (event.target.value) onToggle(event.target.value)
}}
className="min-h-11 w-full"
>
<NativeSelectOption value="">
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
</NativeSelectOption>
{options.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
)

View file

@ -0,0 +1,102 @@
'use client'
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { TableHead } from '@/components/ui/Table'
import { cn } from '@/lib/utils'
type CampaignSortableHeadProps = {
active: boolean
align?: 'left' | 'center' | 'right'
children: ReactNode
className?: string
dir: 'asc' | 'desc'
filter?: ReactNode
href: string
nextDir: 'asc' | 'desc'
sortLabel: string
wrapSortControl?: (
control: ReactElement<{
onPointerUp?: (event: PointerEvent) => void
ref?: Ref<HTMLElement>
}>,
) => ReactNode
}
export const CampaignSortableHead = ({
active,
align = 'left',
children,
className,
dir,
filter,
href,
nextDir,
sortLabel,
wrapSortControl,
}: CampaignSortableHeadProps) => {
const sortControl = (
<CampaignTransitionAnchor
href={href}
replace
scroll={false}
className={cn(
'group inline-flex min-h-11 items-center gap-1 rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'hover:text-foreground',
active ? 'font-medium' : 'font-normal',
align === 'right' && !filter && 'w-full justify-end',
align === 'center' && !filter && 'w-full justify-center',
)}
aria-label={
active
? `Ordenar por ${sortLabel}, inverter para ${nextDir === 'asc' ? 'crescente' : 'decrescente'}`
: `Ordenar por ${sortLabel} (${nextDir === 'asc' ? 'crescente' : 'decrescente'})`
}
>
<span>{children}</span>
{active ? (
dir === 'asc' ? (
<ChevronUpIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
)
) : (
<ChevronsUpDownIcon
className="size-3.5 shrink-0 opacity-60 group-hover:opacity-100 group-focus-visible:opacity-100"
aria-hidden="true"
/>
)}
</CampaignTransitionAnchor>
)
const labeledSort = wrapSortControl ? wrapSortControl(sortControl) : sortControl
return (
<TableHead
aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'}
className={cn(
'text-muted-foreground',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
align === 'left' && 'text-left',
className,
)}
>
{filter ? (
<div
className={cn(
'flex items-center',
align === 'right' && 'justify-end',
align === 'center' && 'justify-center',
)}
>
{labeledSort}
{filter}
</div>
) : (
labeledSort
)}
</TableHead>
)
}

View file

@ -4,6 +4,7 @@ import {
HandshakeIcon,
HomeIcon,
InboxIcon,
MapIcon,
MapPinIcon,
UserCogIcon,
Users2Icon,
@ -24,6 +25,7 @@ export type CampaignNavItem = {
const staffNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Municípios', href: '/campanha/municipios', icon: MapPinIcon },
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
@ -74,7 +76,12 @@ export const getCampaignBottomNav = (role: CampaignUser['role']): CampaignNavIte
if (role === 'leader') return nav
return nav
.filter((item) => item.href !== '/campanha/apoiadores' && item.href !== '/campanha/assessores')
.filter(
(item) =>
item.href !== '/campanha/territorios' &&
item.href !== '/campanha/apoiadores' &&
item.href !== '/campanha/assessores',
)
.slice(0, 5)
}

View file

@ -0,0 +1,66 @@
import {
parseTerritoryListParams,
territoryListStateToRawParams,
type TerritoryCoverage,
type TerritoryListState,
} from '@/utilities/territoryListUrl'
export type TerritoryFilterParam = 'region' | 'coverage'
export type TerritoryFilterOption = {
value: string
label: string
}
export const territoryCoverageLabels = {
com_assessor: 'Cobertura completa',
sem_assessor: 'Com lacunas de assessoria',
} as const satisfies Record<TerritoryCoverage, string>
export const territoryCoverageOptions = (
['com_assessor', 'sem_assessor'] as const satisfies readonly TerritoryCoverage[]
).map((value) => ({ value, label: territoryCoverageLabels[value] }))
export const toggleTerritoryRegionFilter = (
state: TerritoryListState,
region: string,
): TerritoryListState => {
const current = state.regions ?? []
const regions = current.some((entry) => entry === region)
? current.filter((entry) => entry !== region)
: [...current, region]
return parseTerritoryListParams({
...territoryListStateToRawParams(state),
region: regions,
})
}
export const toggleTerritoryCoverageFilter = (
state: TerritoryListState,
coverage: TerritoryCoverage,
): TerritoryListState =>
parseTerritoryListParams({
...territoryListStateToRawParams(state),
coverage: state.coverage === coverage ? undefined : coverage,
})
export const clearTerritoryListFilters = (state: TerritoryListState): TerritoryListState => ({
sort: state.sort,
dir: state.dir,
})
export const isTerritoryFilterActive = (
state: TerritoryListState,
param: TerritoryFilterParam,
): boolean => (param === 'region' ? Boolean(state.regions?.length) : Boolean(state.coverage))
const firstTerritoryNamesLabel = (names: readonly string[]): string =>
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
export const formatTerritoryActiveFiltersSummary = (state: TerritoryListState): string | null => {
const parts: string[] = []
if (state.regions?.length) parts.push(firstTerritoryNamesLabel(state.regions))
if (state.coverage) parts.push(territoryCoverageLabels[state.coverage])
if (state.q) parts.push(`Busca "${state.q}"`)
return parts.length ? parts.join(' · ') : null
}

View file

@ -0,0 +1,191 @@
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import {
allParamValues,
firstValue,
inspectRawListParams,
normalizedText,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import type { TerritorySortDir, TerritorySortKey } from '@/utilities/territoryOverview'
export type TerritoryListSortKey = TerritorySortKey
export type TerritoryListSortDirection = TerritorySortDir
export type TerritoryCoverage = 'com_assessor' | 'sem_assessor'
export type TerritoryListState = {
q?: string
regions?: BahiaIdentityTerritory[]
coverage?: TerritoryCoverage
sort?: TerritoryListSortKey
dir?: TerritoryListSortDirection
}
export type TerritoryListSearchParams = RawSearchParams
const territoryListParamNames = ['q', 'region', 'coverage', 'sort', 'dir'] as const
const territoryListParamNameSet = new Set<string>(territoryListParamNames)
const territoryListSortKeys: TerritoryListSortKey[] = [
'region',
'municipalities',
'votes2022',
'pct',
'validVotes2022',
'estimate2026',
'coverage',
]
const territoryListSortKeySet = new Set<string>(territoryListSortKeys)
const territoryListSortDirSet = new Set<TerritoryListSortDirection>(['asc', 'desc'])
const DEFAULT_TERRITORY_LIST_SORT_KEY: TerritoryListSortKey = 'pct'
const DEFAULT_TERRITORY_LIST_SORT_DIR: TerritoryListSortDirection = 'desc'
export const territoryListSortLabels: Record<TerritoryListSortKey, string> = {
region: 'Território',
municipalities: 'Municípios',
votes2022: 'Votos 2022',
pct: '% da própria votação',
validVotes2022: 'Válidos 2022',
estimate2026: 'Estimativa 2026 (média)',
coverage: 'Assessoria',
}
const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
const parseRegions = (value: string | string[] | undefined): BahiaIdentityTerritory[] => {
const regions: BahiaIdentityTerritory[] = []
const seen = new Set<BahiaIdentityTerritory>()
for (const token of allParamValues(value)) {
const region = canonicalTerritoryBySearchValue.get(normalizeSearchPhrase(token))
if (!region || seen.has(region)) continue
seen.add(region)
regions.push(region)
}
return regions
}
export const defaultTerritoryListSortDir = (
key: TerritoryListSortKey,
): TerritoryListSortDirection => (key === 'region' ? 'asc' : 'desc')
export const resolveTerritoryListSort = (
state: TerritoryListState,
): { sort: TerritoryListSortKey; dir: TerritoryListSortDirection } => {
const sort = state.sort ?? DEFAULT_TERRITORY_LIST_SORT_KEY
return { sort, dir: state.dir ?? defaultTerritoryListSortDir(sort) }
}
export const formatTerritoryListSortSummary = (
sort: TerritoryListSortKey,
dir: TerritoryListSortDirection,
): string => {
if (sort === 'region') {
return dir === 'asc' ? 'Ordenado por território (A–Z)' : 'Ordenado por território (Z–A)'
}
return `Ordenado por ${territoryListSortLabels[sort]} (${dir === 'asc' ? 'menor primeiro' : 'maior primeiro'})`
}
export const parseTerritoryListParams = (params: TerritoryListSearchParams): TerritoryListState => {
const q = normalizedText(firstValue(params.q))
const regions = parseRegions(params.region)
const rawCoverage = firstValue(params.coverage)
const rawSort = firstValue(params.sort)
const rawDir = firstValue(params.dir)
return {
...(q ? { q } : {}),
...(regions.length ? { regions } : {}),
...(rawCoverage === 'com_assessor' || rawCoverage === 'sem_assessor'
? { coverage: rawCoverage }
: {}),
...(rawSort && territoryListSortKeySet.has(rawSort)
? { sort: rawSort as TerritoryListSortKey }
: {}),
...(rawDir && territoryListSortDirSet.has(rawDir as TerritoryListSortDirection)
? { dir: rawDir as TerritoryListSortDirection }
: {}),
}
}
export const territoryListStateToRawParams = (
state: TerritoryListState,
): TerritoryListSearchParams => ({
q: state.q,
region: state.regions,
coverage: state.coverage,
sort: state.sort,
dir: state.dir,
})
export const serializeTerritoryListSearchParams = (state: TerritoryListState): URLSearchParams => {
const canonical = parseTerritoryListParams(territoryListStateToRawParams(state))
const { sort, dir } = resolveTerritoryListSort(canonical)
const params = new URLSearchParams()
if (canonical.q) params.set('q', canonical.q)
for (const region of canonical.regions ?? []) params.append('region', region)
if (canonical.coverage) params.set('coverage', canonical.coverage)
if (sort !== DEFAULT_TERRITORY_LIST_SORT_KEY || dir !== DEFAULT_TERRITORY_LIST_SORT_DIR) {
params.set('sort', sort)
if (dir !== defaultTerritoryListSortDir(sort)) params.set('dir', dir)
}
return params
}
export const buildTerritoryListHref = (state: TerritoryListState): string => {
const query = serializeTerritoryListSearchParams(state).toString()
return query ? `/campanha/territorios?${query}` : '/campanha/territorios'
}
export const buildTerritorySortHref = (
state: TerritoryListState,
nextKey: TerritoryListSortKey,
): string => {
const current = resolveTerritoryListSort(state)
const dir =
current.sort === nextKey
? current.dir === 'asc'
? 'desc'
: 'asc'
: defaultTerritoryListSortDir(nextKey)
return buildTerritoryListHref({ ...state, sort: nextKey, dir })
}
export const resolveTerritoryListUrl = (
params: TerritoryListSearchParams,
): { state: TerritoryListState; href: string; redirectHref?: string } => {
const state = parseTerritoryListParams(params)
const href = buildTerritoryListHref(state)
const canonicalQuery = serializeTerritoryListSearchParams(state).toString()
const raw = inspectRawListParams(params, territoryListParamNameSet)
return {
state,
href,
...(raw.hasUnsupportedParams || raw.query !== canonicalQuery ? { redirectHref: href } : {}),
}
}
const sortOptionLabel = (key: TerritoryListSortKey, dir: TerritoryListSortDirection): string => {
const label = territoryListSortLabels[key]
if (key === 'region') return `${label} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
return `${label} (${dir === 'asc' ? 'menor → maior' : 'maior → menor'})`
}
export const territoryListSortOptions = territoryListSortKeys.flatMap((key) =>
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
)
export const serializeTerritorySortValue = (
key: TerritoryListSortKey,
dir: TerritoryListSortDirection,
): string => `${key}|${dir}`
export const parseTerritorySortValue = (
value: string,
): { key: TerritoryListSortKey; dir: TerritoryListSortDirection } | null => {
const [key, dir] = value.split('|')
if (!territoryListSortKeySet.has(key)) return null
if (!territoryListSortDirSet.has(dir as TerritoryListSortDirection)) return null
return { key: key as TerritoryListSortKey, dir: dir as TerritoryListSortDirection }
}

View file

@ -11,6 +11,8 @@
* fixture. The server loader lives in `loadTerritoryOverview.ts`.
*/
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
const SALVADOR_CITY = 'Salvador'
const SALVADOR_SUB_LABEL = 'Salvador (19 zonas)'
const RMS_DEMAIS_SUB_LABEL = 'Demais municípios da RMS'
@ -50,6 +52,10 @@ export type TerritoryOverviewRow = {
subRows?: TerritoryOverviewSubRow[]
}
export type TerritoryTableRow =
| ({ variant: 'parent' } & Omit<TerritoryOverviewRow, 'subRows'>)
| ({ variant: 'sub'; parentRegion: string } & TerritoryOverviewSubRow)
export type TerritorySortKey =
| 'region'
| 'municipalities'
@ -204,3 +210,43 @@ export const sortTerritoryRows = (
return (a - b) * factor
})
}
type TerritoryRowFilters = {
q?: string
regions?: readonly string[]
coverage?: 'com_assessor' | 'sem_assessor'
}
/** Filters top-level territories; display-only sub-rows always follow their parent. */
export const filterTerritoryRows = (
rows: ReadonlyArray<TerritoryOverviewRow>,
filters: TerritoryRowFilters,
): TerritoryOverviewRow[] => {
const query = filters.q ? normalizeSearchPhrase(filters.q) : ''
const selectedRegions = new Set(filters.regions ?? [])
return rows.filter((row) => {
if (query && !normalizeSearchPhrase(row.region).includes(query)) return false
if (selectedRegions.size && !selectedRegions.has(row.region)) return false
if (filters.coverage === 'com_assessor') {
return row.municipalityCount > 0 && row.withAdvisorCount === row.municipalityCount
}
if (filters.coverage === 'sem_assessor') {
return row.withAdvisorCount < row.municipalityCount
}
return true
})
}
/** Flattens display-only sub-rows while preserving their parent adjacency. */
export const flattenTerritoryRows = (
rows: ReadonlyArray<TerritoryOverviewRow>,
): TerritoryTableRow[] =>
rows.flatMap(({ subRows, ...row }) => [
{ ...row, variant: 'parent' as const },
...(subRows ?? []).map((subRow) => ({
...subRow,
variant: 'sub' as const,
parentRegion: row.region,
})),
])

View file

@ -0,0 +1,63 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Territórios de Identidade', () => {
test('staff sorts, filters and opens the municipality queue for a territory', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('territorios-coordinator')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Territórios'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
await page.getByRole('link', { name: 'Territórios', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/territorios$/)
await expect(
page.getByRole('heading', { level: 1, name: 'Territórios de Identidade' }),
).toBeVisible()
await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click()
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)
await page.getByRole('button', { name: 'Filtrar por Território' }).click()
await page.locator('a[href*="/campanha/territorios?region=Irec"]').click()
await expect(page).toHaveURL(/region=Irec%C3%AA/)
await expect(page.getByText('1 território encontrado')).toBeVisible()
await page.getByRole('link', { name: 'Irecê', exact: true }).first().click()
await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/)
})
test('leader cannot open the territories page', async ({ campaign, page }) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const phone = fixtures.phone()
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Liderança Territórios'),
username: phone,
password,
role: 'leader',
},
depth: 0,
})
await campaign.login(page, phone, password)
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
await page.goto('/campanha/territorios')
await expect(page).toHaveURL(/\/campanha\/?$/)
})
})

View file

@ -7,6 +7,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
for (const path of [
'/campanha/login',
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',
'/campanha/demandas',
'/campanha/conceitos',

View file

@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import {
clearTerritoryListFilters,
formatTerritoryActiveFiltersSummary,
toggleTerritoryCoverageFilter,
toggleTerritoryRegionFilter,
} from '@/utilities/territoryListFilters'
import {
buildTerritoryListHref,
buildTerritorySortHref,
parseTerritoryListParams,
resolveTerritoryListSort,
resolveTerritoryListUrl,
serializeTerritoryListSearchParams,
} from '@/utilities/territoryListUrl'
describe('territory list URL contract', () => {
it('parses and canonicalizes supported filters', () => {
expect(
parseTerritoryListParams({
q: ' irecê ',
region: ['Velho Chico', 'Irecê', 'Velho Chico', 'desconhecido'],
coverage: 'sem_assessor',
sort: 'votes2022',
dir: 'asc',
}),
).toEqual({
q: 'irecê',
regions: ['Velho Chico', 'Irecê'],
coverage: 'sem_assessor',
sort: 'votes2022',
dir: 'asc',
})
})
it('omits the default sort pair from the canonical query', () => {
const state = parseTerritoryListParams({ sort: 'pct', dir: 'desc' })
expect(serializeTerritoryListSearchParams(state).toString()).toBe('')
expect(resolveTerritoryListSort(state)).toEqual({ sort: 'pct', dir: 'desc' })
})
it('preserves filters while toggling sort direction', () => {
const state = parseTerritoryListParams({ q: 'chico', region: 'Velho Chico' })
expect(buildTerritorySortHref(state, 'votes2022')).toBe(
'/campanha/territorios?q=chico&region=Velho+Chico&sort=votes2022',
)
expect(
buildTerritorySortHref(
parseTerritoryListParams({ ...state, sort: 'votes2022', dir: 'desc' }),
'votes2022',
),
).toContain('dir=asc')
})
it('redirects unsupported and non-canonical query strings', () => {
expect(resolveTerritoryListUrl({ q: ' Irecê ', unknown: 'x' })).toEqual({
state: { q: 'Irecê' },
href: '/campanha/territorios?q=Irec%C3%AA',
redirectHref: '/campanha/territorios?q=Irec%C3%AA',
})
})
it('clears invalid values instead of serializing them', () => {
const state = parseTerritoryListParams({
coverage: 'talvez',
sort: 'unknown',
dir: 'sideways',
})
expect(state).toEqual({})
expect(buildTerritoryListHref(state)).toBe('/campanha/territorios')
})
})
describe('territory list filter state', () => {
it('toggles region and coverage filters canonically', () => {
const withRegion = toggleTerritoryRegionFilter({}, 'Irecê')
expect(withRegion).toEqual({ regions: ['Irecê'] })
expect(toggleTerritoryRegionFilter(withRegion, 'Irecê')).toEqual({})
const withCoverage = toggleTerritoryCoverageFilter({}, 'sem_assessor')
expect(withCoverage).toEqual({ coverage: 'sem_assessor' })
expect(toggleTerritoryCoverageFilter(withCoverage, 'sem_assessor')).toEqual({})
})
it('clears filters while preserving the selected sort', () => {
expect(
clearTerritoryListFilters({
q: 'irecê',
regions: ['Irecê'],
coverage: 'sem_assessor',
sort: 'votes2022',
dir: 'asc',
}),
).toEqual({ sort: 'votes2022', dir: 'asc' })
})
it('formats a compact active-filter summary', () => {
expect(
formatTerritoryActiveFiltersSummary({
q: 'chico',
regions: ['Irecê', 'Velho Chico'],
coverage: 'sem_assessor',
}),
).toBe('Irecê, Velho Chico · Com lacunas de assessoria · Busca "chico"')
})
})

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
computeTerritoryRollup,
filterTerritoryRows,
flattenTerritoryRows,
sortTerritoryRows,
type TerritoryMunicipalityInput,
} from '@/utilities/territoryOverview'
@ -144,3 +146,51 @@ describe('sortTerritoryRows', () => {
)
})
})
describe('flattenTerritoryRows', () => {
it('places Metropolitano sub-rows immediately after their parent', () => {
const rows = sortTerritoryRows(computeTerritoryRollup(fixture), 'region', 'asc')
const flattened = flattenTerritoryRows(rows)
const metroIndex = flattened.findIndex(
(row) => row.variant === 'parent' && row.region === 'Metropolitano de Salvador',
)
expect(flattened.slice(metroIndex, metroIndex + 3).map((row) => row.variant)).toEqual([
'parent',
'sub',
'sub',
])
expect(flattened[metroIndex + 1]).toMatchObject({
variant: 'sub',
parentRegion: 'Metropolitano de Salvador',
label: 'Salvador (19 zonas)',
})
})
})
describe('filterTerritoryRows', () => {
const rows = computeTerritoryRollup(fixture)
it('combines accent-insensitive search and selected regions', () => {
expect(
filterTerritoryRows(rows, {
q: 'irece',
regions: ['Irecê', 'Velho Chico'],
}).map((row) => row.region),
).toEqual(['Irecê'])
})
it('treats com assessor as complete coverage and sem assessor as any gap', () => {
expect(
filterTerritoryRows(rows, { coverage: 'com_assessor' }).map((row) => row.region),
).toEqual(['Velho Chico'])
expect(
filterTerritoryRows(rows, { coverage: 'sem_assessor' }).map((row) => row.region),
).toEqual(['Irecê', 'Metropolitano de Salvador'])
})
it('keeps Metropolitano sub-rows attached when the parent matches', () => {
const [metropolitano] = filterTerritoryRows(rows, { q: 'metropolitano' })
expect(metropolitano.subRows).toHaveLength(2)
})
})