diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 392a197b..cde70381 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B16 entregue em código (2026-07-25):** filtros no header das colunas em `/campanha/municipios` — funil + Popover (`MunicipalityHeaderFilter`) ao lado do sort do B15, com multi-seleção OR em Território (`region`), Município (`slug`, com busca acento-insensível e o check "Prioritária" no mesmo popover), Assessores (`advisor`, que também hospeda o toggle com/sem assessor) e Tendência (`trend`); "Limpar" nos multi grandes; mobile mantém os selects empilhados. Header sticky resolvendo contra o scroller do `` (sem scroller interno na tabela) e empty state que troca só as linhas de conteúdo — visão geral e linha de filtros continuam montadas, senão o usuário fica preso em zero resultados. Facetas cross-filtradas (as opções de um filtro respeitam os demais, mantendo o que já está selecionado visível) reaproveitando o escopo já carregado em `loadMunicipalityListFilterFacets`. A pedido do usuário, a coluna "Assessoria" saiu: a ordenação `coverage` passou para "Assessores", o filtro com/sem assessor virou opção do popover, e o alerta "sem responsável" do E9 (prioridade alta sem ninguém) migrou para a célula de assessores, onde antes só haveria um "Sem assessor" apagado. Plano: [`filtros-no-header-lista-municipios.md`](../../../docs/plans/filtros-no-header-lista-municipios.md).
- **E16 entregue em código (2026-07-25):** Dossiê do município (pré-agenda) — aba `?tab=dossie` no detalhe (`MunicipalityDossier.tsx`, server component em Suspense) compondo os loaders existentes via `municipalityDossierData.ts` (baseline TSE + rank A11, conta da cadeira E8, pledges, rede de lideranças com frescor `updatedAt`, agenda `actionPlan` `overrideAccess: false`, sinais C12, perfil IBGE A8 com caveat para zonas — reentrada do artefato órfão); caps 8 lideranças / 5 sinais / 3+2 planos com "ver tudo" por seção; novo campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` (o `.json` da migration também sara a cadeia de snapshots pós-`campaign_goals`); form de estratégia + zod + view model estendidos; **print CSS** (chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4 em `styles.css`) — dossiê imprime 1–2 páginas com capa autocontida ("Gerado em"). Int test `municipalityDossierData.int.spec.ts` (mock do `unstable_cache` do baseline). Gate verde (tsc/lint/knip/363 tests/build); Aikido bloqueado por sessão expirada (re-login navegador pendente). Plano: [`dossie-municipio.md`](../../../docs/plans/dossie-municipio.md).
- **E18 entregue em código (2026-07-24):** documentação de conceitos de inteligência — rota staff-only `/campanha/conceitos` (`isCampaignStaff`, `leader` redirecionado) renderizando `src/lib/campaignIntelligenceConcepts.ts`, conteúdo curado por PR ao lado das fórmulas que descreve (não collection Payload). 7 conceitos de E8 agrupados em base do cálculo (válidos projetados, teto do campo) / diagnóstico (captura, share intracampo, roll-off) / meta e cobertura (meta e meta sugerida, cobertura da meta), cada um com o que mede, fórmula em texto, exemplo estático, por que importa e onde aparece na tela. Superfície de leitura, não cards: `article` por conceito com hairline, coluna em `max-w-prose`, índice sticky só em `lg`, `article:target` tintado para orientar quem cai por âncora. Dois caminhos de descoberta: "Saiba mais" por métrica nos tooltips do card (`MetricExplanation` ganhou `conceptID`) e link para a página inteira no Popover `CampaignInfoHint` do título — o Popover é o caminho de **teclado**, porque conteúdo de Tooltip do Radix não é tabulável; para o toque funcionar, `MunicipalityHoverTooltip` passou a tratar o próprio conteúdo como "dentro" no dismiss por `pointerdown` (antes o link era desmontado antes do clique chegar). Entrada na sidebar a pedido do usuário na mesma sessão: `staffSecondaryNav` num `SidebarGroup mt-auto` colado no rodapé da navegação — fora da `staffNav` (os 7 destinos de trabalho) e fora da barra inferior do mobile, porque é referência e não lugar de trabalhar; `getCampaignSecondaryNav` devolve vazio para `leader`. Corrigido no caminho um defeito de E8: `formatElectionNumber` renderizava a meta sugerida fracionária como "100,968" (lida como cem mil) — agora `maximumFractionDigits: 0`. Sem migration, sem collection, sem server action. Cada fatia futura do programa (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array. Plano: [`documentacao-conceitos-campanha.md`](../../../docs/plans/documentacao-conceitos-campanha.md).
- **E9 entregue em código (2026-07-24):** fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). **Fase 0 — correção do E8:** a meta sintética deixou de ser decomposição proporcional ao teto do campo (que dava 2.911 a Vitória da Conquista, de 5.005 votos em 2022, e 813 a Campo Formoso, de 47) e passou a ser ancorada na votação própria de 2022, por cenário — `deriveSuggestedGoalsByScenario` em [`municipalityPotential.ts`](../../../src/utilities/municipalityPotential.ts): pessimista = base×(1−`margin`), central = base ("repetir 2022"), otimista = base×(`stateGoal` ÷ Σ base), com clamp em `max(central, otimista)` quando `stateGoal` < Σ base; `goalCoverage.ts` passou a receber `SuggestedGoalByScenario` no lugar do escalar, deixando `expectedVotes[S] ?? suggestedGoal[S]` simétrico. `stateGoal`/`margin` do global `campaignGoals` mantêm papel de cálculo (fator 1,163 = "2022 mais 16%"; corte pessimista, fallback 10%) — só `admin.description` mudou, daí a ausência de migration. **Fase 1 — frescor e ordenações:** `declaredAt`/`estimatedAt` entraram no `select` do agregado de pledges (`lastPledgeAt`), `lastSignalAt = max(municipality.lastUpdateAt, lastPledgeAt)` no view model, e duas sort keys novas — `deficit` (**novo default do staff**, desc) e `frescor` (sinal mais frio primeiro, nunca-teve-sinal na frente), ambas derivadas em memória sobre o bundle já carregado, no cenário `central` fixo. **Fase 2 — UI (Impeccable B):** frescor dobrado na coluna "Última atualização" ("há N dias", frio a partir de 21 dias), badge "sem responsável" na célula de assessoria quando `priority === 'alta'` e zero assessores, "coluna da vergonha" como `detail` da métrica de assessoria linkando `?priority=alta&coverage=sem_assessor`, tooltip da célula de cobertura nomeando o cenário ativo, e correção de copy Praças→Municípios no overview. O `critique` trocou o tom do sinal frio de `text-destructive` para `text-estimate-pending-foreground`: com badge de prioridade e "sem responsável" na mesma linha, o vermelho perdia impacto. `/simplify` pós-entrega (validado por três revisores paralelos): consolidou o comparador "latest de dois ISO timestamps" (`resolveMunicipalityLastSignalAt`/`laterTimestamp` eram a mesma função escrita duas vezes) num `latestIsoTimestamp` compartilhado em `campaignTime.ts`; removeu o passthrough morto `baseTotal`/`growthFactor`/`belowBase` e renomeou `computeStatewideGoalDecomposition` → `computeStatewideSuggestedGoals` (nada mais é "decomposto" desde a Fase 0); unificou o badge de assessor duplicado entre tabela/card (tinham divergido em qual ícone renderiza); e consolidou três literais de "cenário zerado" num `zeroByVoteEstimateScenario()` em `voteEstimate.ts`. **Cortes registrados no plano:** votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária (a célula do E8 já mostra % + déficit assinado); `sanityCheckSuggestedGoalsByTerritory` permanece sem consumidor de produção (E11/E12). Verificação: tsc/lint/knip limpos, 337 unit + 366 int testes verdes, build verde; Aikido sem achado novo. Plano: [`fila-de-alocacao.md`](../../../docs/plans/fila-de-alocacao.md).
@@ -17,7 +18,7 @@ alwaysApply: false
- **Avaliação: reorder de colunas por DnD (2026-07-24):** pedido para arrastar headers em `/campanha/municipios` → **fora de escopo** (não confundir com **B17** hide/show). Tecnicamente possível (~1–1,5d), mas conflita com sort B15 + filtros B16 no mesmo header, anti spreadsheet do PRODUCT, e não desbloqueia decisão de alocação. Gatilho em [`reordenar-colunas-lista-municipios.md`](../../../docs/plans/reordenar-colunas-lista-municipios.md).
- **B17 registrado (2026-07-24):** seletor **mostrar/ocultar** colunas em `/campanha/municipios` (desktop) — Popover + `localStorage`; `name` obrigatória; soft B15/B16; prepara viewport para E9. Distinto do reorder DnD (fora de escopo). Sem migration. Plano: [`seletor-colunas-lista-municipios.md`](../../../docs/plans/seletor-colunas-lista-municipios.md).
- **A11 entregue em código (2026-07-24):** posição em votos (rank denso 435 + % da própria votação estadual) no `MunicipalityBaselineCard` e coluna/ordenação `?sort=votos` na lista (default desc; header=`2022`); helper puro [`municipalityVoteRank.ts`](../../../src/lib/municipalityVoteRank.ts); consome o contrato URL `sort`/`dir` de B15. Plano: [`ranking-votos-municipio.md`](../../../docs/plans/ranking-votos-municipio.md).
-- **B16 registrado (2026-07-24):** filtros no header das colunas em `/campanha/municipios` — relocaciona params URL existentes (`region`/`kind`/`trend`/`coverage`/`priority`) para o `TableHead` ao lado do sort B15; barra slim (busca + Limpar [+ Cenário]); mobile mantém disclosure. Sem migration. Plano: [`filtros-no-header-lista-municipios.md`](../../../docs/plans/filtros-no-header-lista-municipios.md).
+- **B16 registrado (2026-07-24; entregue 2026-07-25 — ver acima):** filtros no header das colunas em `/campanha/municipios` — relocaciona params URL existentes (`region`/`kind`/`trend`/`coverage`/`priority`) para o `TableHead` ao lado do sort B15; barra slim (busca + resumo + Limpar); mobile selects empilhados. Sem migration. Plano: [`filtros-no-header-lista-municipios.md`](../../../docs/plans/filtros-no-header-lista-municipios.md).
- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI).
- **B15 entregue (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Verificação (tsc/lint/test/build/Aikido) passou. `/simplify` concluído no mesmo dia: reduziu os comparadores de ordenação a um helper genérico, unificou os dois ramos `payload.find` nativo/derivado, moveu labels/opções de sort para `municipalityUi.ts`, simplificou o ícone de chevron e removeu o cast `as unknown` do mock de roteador nos testes. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md).
- **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
diff --git a/.impeccable/critique/2026-07-25T02-37-03Z__src-components-campaign-municipalitylist-tsx.md b/.impeccable/critique/2026-07-25T02-37-03Z__src-components-campaign-municipalitylist-tsx.md
new file mode 100644
index 00000000..6b81b4a9
--- /dev/null
+++ b/.impeccable/critique/2026-07-25T02-37-03Z__src-components-campaign-municipalitylist-tsx.md
@@ -0,0 +1,41 @@
+---
+target: B16 municipality header filters
+total_score: 25
+p0_count: 0
+p1_count: 3
+timestamp: 2026-07-25T02-37-03Z
+slug: src-components-campaign-municipalitylist-tsx
+---
+# Critique — B16 Municipality Header Filters
+
+Method: dual-agent (A: 8cdcb249-95fe-4f3d-8937-081b10b442bc · B: c641769d-fc5c-4a83-9442-4329cff5cf96)
+
+## Design Health Score
+
+| # | Heuristic | Score | Key Issue |
+|---|-----------|-------|-----------|
+| 1 | Visibility of System Status | 2 | Control lag until RSC (fixed post-critique: optimistic) |
+| 2 | Match System / Real World | 3 | Priority on Município column |
+| 3 | User Control and Freedom | 3 | Limpar + Todos clear |
+| 4 | Consistency and Standards | 2 | Header vs mobile select duality |
+| 5 | Error Prevention | 3 | Constrained options |
+| 6 | Recognition Rather Than Recall | 2 | Icon-only funnels (Priority now labeled) |
+| 7 | Flexibility and Efficiency | 3 | Column-local filters |
+| 8 | Aesthetic and Minimalist Design | 3 | Slim bar succeeds |
+| 9 | Error Recovery | 2 | Limpar primary recovery |
+| 10 | Help and Documentation | 2 | Filter meanings sparse |
+| **Total** | | **25/40** | **Acceptable** |
+
+## Anti-Patterns Verdict
+
+Not AI slop. Detector: 0 findings. Browser: skipped (auth/DB).
+
+## Priority Issues (as found)
+
+- P1 optimistic control feedback — **fixed in polish**
+- P1 Priority discoverability — **fixed: labeled "Prioridade" trigger**
+- P1 Region wall — **fixed: search-within-popover when ≥8 options**
+- P2 truncated summary — **mitigated: allow wrap**
+- P2 Assessoria vs Cobertura — deferred (label already Assessoria)
+
+No P0.
diff --git a/docs/plans/filtros-no-header-lista-municipios.md b/docs/plans/filtros-no-header-lista-municipios.md
index 587e70fb..5bbda6f7 100644
--- a/docs/plans/filtros-no-header-lista-municipios.md
+++ b/docs/plans/filtros-no-header-lista-municipios.md
@@ -1,15 +1,15 @@
# B16 — Filtros no header das colunas da lista de Municípios
-Status: rascunho
+Status: entregue
Atualizado em: 2026-07-24
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B16; superfície de coordenação)
Impeccable: B — encaixe em `MunicipalityList` / `MunicipalitySortableHead` / `MunicipalityFilters` em `/campanha/municipios`; sem rota nova
-Appetite: ~1–1,5 dia eng; relocação dos selects de recorte para o header (desktop) + barra slim + mobile disclosure; sem migration, sem collection, sem Consent
+Appetite: ~1–1,5 dia eng; relocação dos selects de recorte para o header (desktop) + barra slim + mobile stacked selects; sem migration, sem collection, sem Consent
Responsável: —
## Design (Impeccable)
-Âncoras: `PRODUCT.md` (princípios 2 — clareza sob pressão — e 8 — Feel the action) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `MunicipalitySortableHead`, `CampaignListPendingBoundary`, shadcn `Table` / `NativeSelect` / `Popover` se precisar.
+Âncoras: `PRODUCT.md` (princípios 2 — clareza sob pressão — e 8 — Feel the action) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · shells `MunicipalitySortableHead`, `CampaignListPendingBoundary`, shadcn `Table` / `NativeSelect` / `Popover`.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (só composição header+filtro; sem redesign da lista/overview).
@@ -31,57 +31,54 @@ Brief compacto:
## Contexto
-Em `/campanha/municipios`, o estado canônico de lista já vive na URL via `MunicipalityListState` (`municipalityUi.ts`): `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `page`, e desde **B15** `sort`/`dir` com headers clicáveis (`MunicipalitySortableHead`). Os filtros de recorte ainda estão numa **fileira separada** (`MunicipalityFilters`) acima do overview — o olho alterna entre barra e tabela. Sort e filtro não compartilham affordance espacial.
+Em `/campanha/municipios`, o estado canônico de lista já vive na URL via `MunicipalityListState` (`municipalityUi.ts`): `q`, `region`, `kind`, `coverage`, `priority`, `trend`, `page`, e desde **B15** `sort`/`dir` com headers clicáveis (`MunicipalitySortableHead`). Os filtros de recorte ainda estavam numa **fileira separada** (`MunicipalityFilters`) acima do overview — o olho alternava entre barra e tabela.
-Mapeamento atual coluna ↔ filtro URL (staff):
+Mapeamento coluna ↔ filtro URL (staff) — as-built pós-E8:
-| Coluna (desktop) | Sort (B15) | Filtro URL hoje |
-| ------------------------ | --------------- | -------------------------------------------------------------------------- |
-| Município | `name` | `q` (busca; não é select) + `priority` (badge/ícone na célula, sem coluna) |
-| Território de identidade | `region` | `region` |
-| Tipo | `kind` | `kind` |
-| 2022 (`votos`) | `votos` | — |
-| Assessores | — (editor B9) | — (filtro “Assessoria” alimenta a coluna **Cobertura**) |
-| Tendência | `trend` | `trend` |
-| Votos estimados | `expectedVotes` | — |
-| Última atualização | `lastUpdateAt` | — |
-| Cobertura | `coverage` | `coverage` |
-
-Pedido de produto (2026-07-24): avaliar juntar os filtros aos headers, **idealmente no header de cada coluna**, mantendo sort.
-
-Fill-ins vizinhos: [Cenário junto aos filtros](cenario-junto-filtros-municipios.md) (assume fileira de filtros); [ícone de prioridade](icone-prioridade-lista-municipios.md) (coluna do nome). **E9** herda a mesma lista/URL — o padrão de header deve sobreviver à fila.
+| Coluna (desktop) | Sort (B15) | Filtro URL (B16) |
+| ------------------ | --------------- | ------------------------------------------------------------------------------------ |
+| Município | `name` | `priority` (checkbox) + `slug` (multi, com busca) |
+| Território | `region` | `region` (multi, com busca) |
+| Tipo | `kind` | `kind` |
+| 2022 (`votos`) | `votos` | — |
+| Assessores | `coverage` | `coverage` (com/sem assessor) + `advisor` (multi) — **não** "Cobertura da meta" (E8) |
+| Tendência | `trend` | `trend` (multi; todos ou nenhum = todas) |
+| Votos estimados | `expectedVotes` | — |
+| Última atualização | `lastUpdateAt` | — |
+| Cobertura da meta | — | — (estática; E8) |
## Objetivos
- Desktop (`md+`): para cada filtro URL já existente que tem coluna correspondente, o controle de filtro vive no `TableHead` daquela coluna, **ao lado** do sort B15 (sem perder `aria-sort` / toggle `dir`).
- Semântica URL intacta (`parse`/`build`/`resolveMunicipalityListUrl`); pending via `CampaignListPendingBoundary` / `commitNavigation` (Feel the action).
-- Barra superior slim: **busca** (`q`) + **Limpar** (+ **Cenário** quando o fill-in de Cenário entregar; mobile: disclosure com os mesmos filtros).
-- Mobile (cards): filtros **não** fingem header — permanecem no disclosure/barra (padrão B15 do select “Ordenar”).
+- Barra superior slim: **busca** (`q`) + **resumo dos filtros ativos** + **Limpar** (+ **Cenário** quando o fill-in de Cenário entregar; mobile: selects empilhados).
+- Mobile (cards): filtros **não** fingem header — permanecem empilhados em `md:hidden` (sem disclosure novo — não havia precedente no repo).
- Guardrails: sem migration, sem collection, sem Consent, sem server action; `leader` continua redirecionado; staff-only filters só com `showStaffFilters` / `isStaffView`.
## Decisões travadas
-- **Item de trilha B16 (não fill-in; não só R6; não reabrir B15).** Pattern de composição da lista que E9 consome; ~1–1,5d; B15 já entregue o contrato sort. (2026-07-24, roadmap-item.) **Rejeitado:** fill-in só (subestima conflito sort×filtro e o destino da barra); absorver em R6 (atrasa e dilui); fase informal de B15 (entrega fechada).
-- **Só relocacionar filtros URL existentes — não inventar where novos.** v1: `region`, `kind`, `trend`, `coverage`, `priority` no header; `q` fica na barra (texto livre). **Rejeitado:** filtro por faixa de `expectedVotes` / frescor / rank 2022 neste item (rabbit hole de produto + UI numérica); filtro no header de Assessores (ambíguo com editor B9 — o where “com/sem assessor” fica no header **Cobertura**, label do select pode continuar “Assessoria” ou alinhar a “Cobertura” no craft).
-- **Sort = clique no rótulo/chevron (B15); filtro = controle irmão no mesmo `TableHead`.** Dois alvos distintos (`CampaignTransitionAnchor` vs `NativeSelect`/`Popover`), `stopPropagation` no filtro. **Rejeitado:** clique único ciclando sort+filter; segunda linha de cells tipo Excel sob o header (altura + spreadsheet vibe); TanStack Table.
-- **Desktop: removes selects duplicados da fileira superior** para os params que migraram ao header. Barra slim = busca + Limpar (+ Cenário). **Rejeitado:** manter barra completa + header (duplicata); eliminar a barra inteira (busca/Limpar/Cenário precisam de casa).
-- **Mobile: disclosure com os filtros atuais** (e sort select B15). **Rejeitado:** inventar “headers” em cards.
-- **Prioridade no header da coluna Município** (select Todos / Prioritárias), coexistindo com o fill-in do ícone Flag na célula. **Rejeitado:** coluna Prioridade nova só para o filtro.
-- **i18n e naming:** identificadores ingleses (`MunicipalityColumnHead`, `MunicipalityHeaderFilter`, estender `MunicipalitySortableHead` ou composição); labels pt-BR inalterados na essência.
+- **Item de trilha B16 (não fill-in; não só R6; não reabrir B15).** Pattern de composição da lista que E9 consome; ~1–1,5d; B15 já entregue o contrato sort. (2026-07-24, roadmap-item.)
+- **Só relocacionar filtros URL existentes — não inventar where novos.** v1: `region`, `kind`, `trend`, `coverage`, `priority` no header; `q` fica na barra (texto livre).
+- **Sort = clique no rótulo/chevron (B15); filtro = funil irmão no mesmo `TableHead` → Popover uniforme.** Opções navegam por `CampaignTransitionAnchor`. **Rejeitado:** NativeSelect inline no header; híbrido NativeSelect+Popover.
+- **Desktop: removes selects duplicados da fileira superior.** Barra slim = busca + resumo + Limpar (+ Cenário).
+- **Mobile: selects empilhados** (não disclosure). **Rejeitado:** inventar Collapsible neste item.
+- **Prioridade no header da coluna Município** com trigger **rotulado** "Prioridade" (discoverability pós-critique).
+- **Coluna "Assessoria" removida (2026-07-25, produto):** ela repetia a informação de "Assessores". O param `coverage` sobrevive — ordena a coluna Assessores (por nº de assessores, label de sort "Assessores") e filtra "Com/Sem assessor" no mesmo popover, acima da lista de assessores; no mobile continua como select próprio ("Assessoria"). Segue **não** sendo a coluna "Cobertura da meta" (E8).
+- **i18n e naming:** `MunicipalityHeaderFilter`, `MunicipalityFilterParam`, estender `MunicipalitySortableHead`; labels pt-BR.
-## Questões em aberto
+## Questões em aberto (resolvidas na implementação)
-- **Controle no header: `NativeSelect` compacto vs ícone Filter → Popover?** **Opções:** A) NativeSelect sob/ao lado do rótulo | B) ícone Filter + Popover com as opções | C) DropdownMenu. **Recomendação:** **B** se a largura da coluna apertar (TI é longo); **A** em colunas curtas (Tipo, Cobertura) — decidir no craft medindo a tabela real; default de implementação: Popover+lista para TI, NativeSelect para enums curtos. _(assumido — validar no craft)_
-- **Fill-in Cenário: aterrar antes ou depois de B16?** **Opções:** A) Cenário primeiro (barra ainda “cheia”) | B) B16 primeiro (barra slim; Cenário encaixa na slim) | C) mesmo PR. **Recomendação:** **B** ou **C** se o implementador pegar os dois — B16 redefine o destino da barra; o plano de Cenário já prevê fileira de `MunicipalityFilters` (atualizar destino → slim bar no craft). _(assumido)_
-- **Label do filtro `coverage` no header Cobertura: “Assessoria” vs “Cobertura”?** **Opções:** A) manter “Assessoria” (copy atual do select) | B) “Cobertura” alinhado ao header | C) “Com assessor”. **Recomendação:** **B** no header (contexto da coluna); opções internas `Com assessor` / `Sem assessor` inalteradas.
+- **Controle no header:** Popover uniforme (confirmado com produto na sessão de plano).
+- **Fill-in Cenário:** fora deste PR — barra slim reserva o slot.
+- **Label coverage:** sort/coluna **Assessores**; select mobile **Assessoria** (auditoria pós-E8).
-## Abordagem proposta
+## Abordagem proposta (as-built)
```mermaid
flowchart LR
URL["MunicipalityListState\nURL where + sort/dir"]
- Slim["MunicipalityFilters slim\nq + Limpar + Cenário + mobile"]
- Head["MunicipalityColumnHead\nsort + filter"]
+ Slim["MunicipalityFilters slim\nq + resumo + Limpar + mobile"]
+ Head["MunicipalitySortableHead\n+ MunicipalityHeaderFilter"]
List["MunicipalityList desktop"]
Loader["loadMunicipalityListPageBundle"]
Pending["CampaignListPendingBoundary"]
@@ -97,48 +94,61 @@ flowchart LR
Componentes:
-- **`MunicipalityColumnHead`** (novo ou extensão de `MunicipalitySortableHead` em `src/components/campaign/`): `TableHead` com (1) âncora de sort B15 e (2) slot opcional de filtro (`filterParam` + options). Cliente leve; reusa `buildMunicipalityListHref` / `commitNavigation` pattern / pending compartilhado. Depth: **não** extrair factory multi-lista (<3 call sites).
-- **`MunicipalityList.tsx`**: passar state + wire dos filtros por coluna; coluna Assessores permanece `TableHead` estático (sem filtro/sort).
-- **`MunicipalityFilters.tsx`**: desktop slim (busca + Limpar [+ Cenário]); em `md:hidden` (ou disclosure) manter os NativeSelects de `region`/`kind`/… espelhando a URL; remover duplicata desktop dos params migrados.
-- **`municipalityUi.ts`**: sem mudança de contrato URL se os params forem os mesmos; helpers opcionais `municipalityHeaderFilterOptions` para labels/options por param (evitar duplicar arrays no JSX).
-- **Migration:** Sem migration, sem collection, sem server action.
+- **`MunicipalityHeaderFilter`** — funil / FunnelPlus + Popover; optimistic enquanto pending; busca no Popover quando ≥8 opções; Prioridade rotulada.
+- **`MunicipalitySortableHead`** — prop opcional `filterParam`.
+- **`MunicipalityFilters`** — desktop slim; mobile `md:hidden` a partir de `municipalityFilterDefinitions`.
+- **`municipalityUi.ts`** — `municipalityFilterDefinitions`, `applyMunicipalityFilterValue`, `buildMunicipalityFilterHref`, `municipalityListFilterParts`, `formatMunicipalityActiveFiltersSummary`.
## Dependências
-- **Dura:** nenhuma de item aberto — **B15 ✓** (sort no header + `sort`/`dir`) é pré-requisito já entregue.
-- **Suaves:** fill-in Cenário (destino = barra slim); fill-in ícone prioridade (célula do nome); **E9** (herda o pattern).
+- **Dura:** nenhuma — **B15 ✓** já entregue.
+- **Suaves:** fill-in Cenário (destino = barra slim); fill-in ícone prioridade; **E9** (herda o pattern).
## Não escopo
-- Novos params de filtro (faixa de votos, frescor, rank) — produto futuro / E9 se a fila pedir.
-- Sort na coluna Assessores; filtro “por assessor nomeado” (pessoa) — fora; coverage binário basta.
-- Unificar headers filtráveis em apoiadores/lideranças — fill-in sob demanda (3º call site).
+- Novos params de filtro (faixa de votos, frescor, rank) — produto futuro / E9.
+- Sort na coluna Assessores; filtro “por assessor nomeado”.
+- Unificar headers filtráveis em apoiadores/lideranças.
- Redesign do overview / mapa; R6 glossário.
-- Lib de data-grid / reorder/resize de colunas — avaliação dedicada em [reordenar-colunas-lista-municipios.md](reordenar-colunas-lista-municipios.md) (fora de escopo com gatilho; não B17 agora).
+- Lib de data-grid / reorder de colunas.
## Rabbit holes
-- **Spreadsheet mode.** Header filter+sort ≠ grade editável. **Mitigação:** só navegação URL + B9 Popovers existentes; ban TanStack/Editable grid neste item.
-- **Duplicar pending / segundo `useTransition`.** **Mitigação:** reusar `useCampaignListPending` / `CampaignTransitionAnchor` como B15/filtros-auto.
-- **Abstrair `FilterableSortableHead` genérico cedo.** **Mitigação:** composição local na lista de municípios; extrair no 2º consumidor.
-- **Reescrever o fill-in Cenário como B16.** **Mitigação:** Cenário continua plano próprio; B16 só reserva o slot na slim bar.
+- Spreadsheet mode · Mitigação: só navegação URL + B9 Popovers.
+- Segundo `useTransition` · Mitigação: `useCampaignListPending` / `CampaignTransitionAnchor`.
+- Abstrair `FilterableSortableHead` genérico · Mitigação: 1 call site.
+- Disclosure mobile novo · Mitigação: selects empilhados.
## Adiado com gatilho
-- **Filtro numérico / por faixa em Votos estimados ou 2022.** Revisitar quando: pedido explícito da mesa ou E9 exigir faceta na fila.
-- **Filtro “por assessor” (ID de `campaignUser`).** Revisitar quando: coordenação pedir “só os meus” além do scope de advisor já imposto pelo access.
-- **Shared head filtrável em `components/ui`.** Revisitar quando: 2ª lista `/campanha` pedir o mesmo padrão.
+- Filtro numérico / por faixa · pedido mesa ou E9.
+- Filtro “por assessor” (ID) · coordenação pedir além do scope de advisor.
+- Shared head filtrável em `components/ui` · 2ª lista pedir o padrão.
+- Chips dismissíveis no resumo (P2 critique) · atrito medido no onboarding.
+
+## Notas de implementação (2026-07-24)
+
+- **Auditoria pré-implementação:** mapa de colunas defasado pós-E8 (Assessoria ≠ Cobertura da meta); “disclosure mobile” inexistente → selects empilhados; default sort = `votos` desc (A11).
+- **Affordance:** Popover uniforme (não híbrido). Escopo: só B16 (Cenário fora).
+- **Critique** (dual-agent, score 25/40): 0 P0; 3 P1 fechados (optimistic control, Prioridade rotulada, busca no Popover de TI); P2 summary wrap mitigado. Snapshot: `.impeccable/critique/2026-07-25T02-37-03Z__src-components-campaign-municipalitylist-tsx.md`.
+- **Verificação:** `tsc`/`lint`/`knip`/`test:unit` (328)/`test:int` (verde; 2 timeouts flaky na 1ª passada, ok no retry)/`build` local. Aikido 0 findings nos arquivos editados. E2E `municip` bloqueado por ambiente da máquina (`ENOSPC` → Postgres local caiu / porta 5432 indisponível); não é regressão de produto deste item — revalidar com `pnpm test:e2e` quando o disco/DB local estabilizar.
+
+## Ajustes pós-uso (2026-07-25)
+
+- **Bug do clique que se auto-desfazia** (Prioritária não aplicava; TI selecionado não desmarcava): o `href` de cada opção era derivado do estado **otimista**, e o `onPointerUp` aplicava o otimista **antes** do `click` — o clique navegava para o estado já invertido. Regra agora: `CampaignTransitionAnchor` expõe `onNavigate`, chamado **depois** de o clique fixar o `href`; o otimista é derivado (`{ baseKey, next }` comparado com a chave do estado da URL), sem `useEffect` correndo contra a transição.
+- **Opções cruzadas com os filtros ativos:** `loadMunicipalityListFilterFacets` (em `municipalityPageData.ts`) devolve slugs/territórios/assessores ainda alcançáveis. Cada facet aplica todos os outros filtros e **omite o próprio param** (o conjunto OR continua somável); valores selecionados entram na união para poderem ser desfeitos; `where` idênticos colapsam em uma query (caso comum = 1 leitura).
+- **Peso dos headers:** "Assessores" e "Cobertura da meta" passaram a `font-normal`, igualando os headers ordenáveis inativos.
+- **Rolagem e header fixo:** a tabela não é mais um scroller próprio — `Table` ganhou `containerClassName` e a lista passa `overflow-x-visible`, de modo que o único scroller vertical é o da área de conteúdo (`campaign-content-scroll`) e o `sticky top-0` dos `th` resolve contra ele. Fundo opaco (`bg-background`) + `shadow-[inset_0_-1px_0_var(--border)]` no lugar da borda da `tr` (que não acompanha células sticky) e cantos arredondados nas células das pontas, já que o wrapper perdeu o `overflow-hidden`. Trade-off aceito: uma tabela mais larga que a viewport passa a rolar junto com a página em vez de ter barra própria.
+- **Tendência multi-escolha:** `state.trend` virou `state.trends[]` (param `trend` repetido). Regra travada: **todas ou nenhuma selecionadas = "todas"** — o `where` só restringe com `0 < n < 3` (senão municípios sem tendência registrada sumiriam), e o mesmo teste governa o ícone "filtro ativo" e o resumo. Sem opção "Todas" no popover. Mobile ganhou `MobileMultiFilterField` (escolher adiciona, escolher de novo remove), agora compartilhado com Assessores. `applyMunicipalityFilterValue` sobrou só para `kind` e virou `applyMunicipalityKindFilter`.
+- **Empty state só nas linhas:** o overview e a linha de header (com os filtros) continuam montados; `MunicipalityListEmptyState` entra numa `tr` com `colSpan` no `tbody` (e no lugar dos cards no mobile). Para isso `loadMunicipalityListPageBundle` passou a devolver overview **zerado** em vez de `null` quando o escopo filtrado é vazio (int test atualizado). Sem isso o usuário ficava sem como desfazer o filtro que zerou a lista.
+- **Verificação visual:** Playwright dirigido contra um dev server no banco de teste — header `position: sticky` colado no topo com o scroller em 900px, `table-container` com `overflow: visible` e sem scroll vertical próprio; empty state com overview + header + 6 botões de filtro presentes; popover de Tendência com as 3 opções e URL `?trend=favoravel&trend=neutra` após dois cliques.
## Referências
- `docs/roadmap.md` (B16; B15 ✓; fill-ins Cenário / prioridade; E9)
-- [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15 — contrato sort/header)
-- [filtros-auto-pracas.md](filtros-auto-pracas.md) (URL + pending + debounce busca)
-- [cenario-junto-filtros-municipios.md](cenario-junto-filtros-municipios.md) (destino da barra)
-- [icone-prioridade-lista-municipios.md](icone-prioridade-lista-municipios.md) (coluna nome)
-- [fila-de-alocacao.md](fila-de-alocacao.md) (E9 — herda lista)
-- `src/components/campaign/MunicipalityList.tsx`, `MunicipalitySortableHead.tsx`, `MunicipalityFilters.tsx`
-- `src/utilities/municipalityUi.ts` — `MunicipalityListState`, href/where
-- `src/components/campaign/CampaignListPending.tsx` — pending compartilhado
-- AGENTS.md — naming EN / strings pt-BR; Campaign auth; sem migration neste item
-- `PRODUCT.md` / `DESIGN.md` — Field Desk, Feel the action, anti spreadsheet
+- [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15)
+- [filtros-auto-pracas.md](filtros-auto-pracas.md)
+- [cenario-junto-filtros-municipios.md](cenario-junto-filtros-municipios.md)
+- `src/components/campaign/MunicipalityList.tsx`, `MunicipalitySortableHead.tsx`, `MunicipalityHeaderFilter.tsx`, `MunicipalityFilters.tsx`
+- `src/utilities/municipalityUi.ts`
+- `PRODUCT.md` / `DESIGN.md`
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 6696c092..77523e27 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); **B19 ✓** gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
+Atualizado em: 2026-07-25 (**B16 ✓** filtros no header da lista; **E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); **B19 ✓** gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -51,6 +51,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
- **B19 (2026-07-24)** — gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, carteira de municípios (auto-save por delta), reenviar link de senha; só CG/candidato (`isCampaignUnrestricted`); access de update/phone e atribuição `municipality.advisors` alinhados ao `candidate`; leitura privilegiada de e-mail no loader (`reloadUnrestrictedActor`); sem migration. Plano: [gerenciar-assessores.md](plans/gerenciar-assessores.md).
- **E9 (2026-07-24)** — fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). Corrigiu antes a meta sintética do E8, que repartia os 150 mil proporcionalmente **só ao teto do campo** e por isso dava meta 2.911 a Vitória da Conquista (5.005 votos em 2022) e 813 a Campo Formoso (47) — ordenar por déficit sobre ela colocaria desertos no topo. `deriveSuggestedGoalsByScenario` (substitui `decomposeStateGoal`) ancora a meta na votação própria de 2022 por cenário: pessimista = base×(1−`margin`), central = base, otimista = base×(`stateGoal` ÷ Σ base), com clamp; `goalCoverage` passou a receber `SuggestedGoalByScenario`. Na fila: `lastPledgeAt` no agregado de pledges, `lastSignalAt` = máx(`lastUpdateAt`, pledge) no view model, sort keys `deficit` (**novo default do staff**) e `frescor` (cenário `central` fixo), frescor dobrado na coluna "Última atualização" (frio ≥ 21 dias), badge "sem responsável" em prioridade alta sem assessor, "coluna da vergonha" no detalhe da métrica de assessoria (`?priority=alta&coverage=sem_assessor`) e copy Praças→Municípios. Cortes: votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária. Plano: [fila-de-alocacao.md](plans/fila-de-alocacao.md). Desbloqueia **E11**.
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
+- **B16 (2026-07-25)** — filtros no header da lista de municípios: `MunicipalityHeaderFilter` (funil + Popover ao lado do sort do B15) com multi-seleção OR em município, território, assessores e tendência, busca acento-insensível e "Limpar"; mobile mantém os selects empilhados; header sticky com a página como scroller e empty state que troca só as linhas, preservando visão geral e filtros; coluna "Assessoria" removida (ordenação foi para "Assessores", com/sem assessor virou opção do popover e o alerta "sem responsável" do E9 passou a morar na célula de assessores). Plano: [filtros-no-header-lista-municipios.md](plans/filtros-no-header-lista-municipios.md).
## Próximos — Campanha (`/campanha`)
@@ -86,7 +87,7 @@ 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)
- ~~**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 — relocaciona `region`/`kind`/`trend`/`coverage`/`priority` para o `TableHead` (desktop) ao lado do sort B15; barra slim (busca + Limpar [+ Cenário]); mobile mantém disclosure · ~1–1,5d · Janela 1–2 · soft: B15 ✓ · cortável · [plano](plans/filtros-no-header-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)
- **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; soft: B15 ✓ (ids) / B16 (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md)
- **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md)
- ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md)
@@ -125,7 +126,7 @@ flowchart TD
B8F2["B8 F2 Polígonos zonas de Salvador"]
B14n["B14 Município mais próximo"]
B15n["B15 ✓ Ordenar lista por coluna"]
- B16n["B16 Filtros no header da lista"]
+ B16n["B16 ✓ Filtros no header da lista"]
B17n["B17 Seletor de colunas da lista"]
B18n["B18 Filtros salvos + atalho sidebar"]
B19n["B19 ✓ Gerenciar assessores"]
@@ -180,11 +181,11 @@ flowchart TD
B8F2 -.ZE Salvador.-> B14n
```
-Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
+Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B17** (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), **B18** (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
### Sequência por janela (só pendentes)
-**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
+**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
@@ -196,7 +197,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`).
-**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
+**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
## Bloqueadores atuais
diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
index 850a2d0a..f78abbd4 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
@@ -3,8 +3,6 @@ import {
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
-import { SearchXIcon } from 'lucide-react'
-import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
@@ -16,15 +14,6 @@ import { MunicipalityFilters } from '@/components/campaign/MunicipalityFilters'
import { MunicipalityList } from '@/components/campaign/MunicipalityList'
import { MunicipalityListOverview } from '@/components/campaign/MunicipalityListOverview'
import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker'
-import { Button } from '@/components/ui/button'
-import {
- Empty,
- EmptyContent,
- EmptyDescription,
- EmptyHeader,
- EmptyMedia,
- EmptyTitle,
-} from '@/components/ui/Empty'
import {
isCampaignCoordinator,
isCampaignLeader,
@@ -38,6 +27,7 @@ import {
buildMunicipalityListVisitHref,
buildMunicipalityListVisitLabel,
getCampaignScopeLabel,
+ municipalityFilterOptionsForSlugs,
resolveMunicipalityListUrl,
} from '@/utilities/municipalityUi'
import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
@@ -69,24 +59,48 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
totalPages,
scopeTotal,
overview,
+ filterFacets,
} = pageBundle
const resolvedUrl = resolveMunicipalityListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
const listVisitLabel = buildMunicipalityListVisitLabel(state)
+ // One read covers both needs: the listed municipalities' avatars and the
+ // (cross-filtered) advisor column-filter labels.
const advisorIDs = [
- ...new Set(listMunicipalities.flatMap((municipality) => municipality.advisorIDs)),
+ ...new Set([
+ ...listMunicipalities.flatMap((municipality) => municipality.advisorIDs),
+ ...filterFacets.advisorIDs,
+ ]),
]
- const advisorSummaries = isStaffView ? await loadAdvisorSummaries(payload, user, advisorIDs) : []
+ const [advisorSummaries, advisorOptions] = await Promise.all([
+ isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
+ // Only the coordinator-only assign-advisors control consumes these.
+ isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
+ ])
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
- const advisorOptions = isCoordinator ? await getEligibleAdvisorOptions(payload, user) : []
+
+ const selectedAdvisorIDs = new Set(state.advisors ?? [])
+ const columnFilterOptions = {
+ name: municipalityFilterOptionsForSlugs(filterFacets.slugs),
+ region: filterFacets.regions.map((region) => ({ value: region, label: region })),
+ advisor: filterFacets.advisorIDs
+ .flatMap((id) => {
+ const name = advisorNamesById.get(id)?.name
+ if (name) return [{ value: String(id), label: name }]
+ // Keep an unknown-but-selected advisor listed so the filter can be undone.
+ return selectedAdvisorIDs.has(id) ? [{ value: String(id), label: `Assessor #${id}` }] : []
+ })
+ .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')),
+ }
const filters = (
)
@@ -97,7 +111,9 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
? null
: buildMunicipalityListHref({ ...state, priority: 'alta', coverage: 'sem_assessor' }, 1)
- const listBody = listMunicipalities.length ? (
+ // The overview and the table's filter header stay mounted even with zero
+ // results — only the rows are replaced by the empty state.
+ const listBody = (
<>
{isStaffView && overview ? (
@@ -108,6 +124,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
isStaffView={isStaffView}
isCoordinator={isCoordinator}
advisorOptions={advisorOptions}
+ columnFilterOptions={columnFilterOptions}
trendFormAction={setMunicipalityPoliticalTrendFormAction}
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
state={state}
@@ -123,23 +140,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
/>
>
- ) : (
-
-
-
-
-
- Nenhum município encontrado
-
- Ajuste a busca ou os filtros. Você só vê municípios dentro do seu escopo.
-
-
-
-
-
-
)
// Shared transition: the filters navigate, the results dim ("Feel the action").
diff --git a/src/components/campaign/CampaignListPending.tsx b/src/components/campaign/CampaignListPending.tsx
index d1072b08..c974d310 100644
--- a/src/components/campaign/CampaignListPending.tsx
+++ b/src/components/campaign/CampaignListPending.tsx
@@ -48,9 +48,15 @@ export const CampaignTransitionAnchor = forwardRef<
href: string
replace?: boolean
scroll?: boolean
+ /**
+ * Optimistic hook, fired only after this click commits to `href`: a control
+ * whose `href` derives from optimistic state must not invert itself before
+ * the navigation it is about to trigger.
+ */
+ onNavigate?: () => void
}
>(function CampaignTransitionAnchor(
- { href, children, replace = false, scroll = true, ...anchorProps },
+ { href, children, replace = false, scroll = true, onNavigate, ...anchorProps },
ref,
) {
const router = useRouter()
@@ -75,6 +81,7 @@ export const CampaignTransitionAnchor = forwardRef<
return
}
event.preventDefault()
+ onNavigate?.()
startTransition(() => {
if (replace) router.replace(href, { scroll })
else router.push(href, { scroll })
diff --git a/src/components/campaign/MunicipalityFilters.tsx b/src/components/campaign/MunicipalityFilters.tsx
index 30154101..79ff4ac4 100644
--- a/src/components/campaign/MunicipalityFilters.tsx
+++ b/src/components/campaign/MunicipalityFilters.tsx
@@ -8,30 +8,78 @@ import { CampaignSearchInput } from '@/components/campaign/CampaignSearchInput'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
-import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
+ applyMunicipalityKindFilter,
buildMunicipalityFiltersKey,
buildMunicipalityListHref,
- municipalityKindLabels,
- municipalityListCoverageLabels,
+ formatMunicipalityActiveFiltersSummary,
+ getMunicipalityFilterDefinition,
+ getMunicipalitySingleFilterValue,
+ municipalityFilterDefinitions,
municipalityListSortOptions,
parseMunicipalitySortValue,
- politicalTrendLabels,
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
+ toggleMunicipalityExclusiveFilterValue,
+ toggleMunicipalityMultiFilterValue,
+ type MunicipalityFilterOption,
type MunicipalityListState,
} from '@/utilities/municipalityUi'
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
+}) => (
+
+ {label}
+ {
+ if (event.target.value) onToggle(event.target.value)
+ }}
+ className="min-h-11 w-full"
+ >
+
+ {selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
+
+ {options.map((option) => (
+
+ {selected.includes(option.value) ? `✓ ${option.label}` : option.label}
+
+ ))}
+
+
+)
+
type MunicipalityFiltersProps = {
state: MunicipalityListState
showStaffFilters: boolean
+ /** Advisor options already narrowed by the other active filters. */
+ advisorFilterOptions: MunicipalityFilterOption[]
}
-export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFiltersProps) => {
+export const MunicipalityFilters = ({
+ state,
+ showStaffFilters,
+ advisorFilterOptions,
+}: MunicipalityFiltersProps) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const sharedPending = useCampaignListPending()
@@ -41,6 +89,12 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef | null>(null)
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
+ const draftQ = normalizedText(search) || state.q
+ const activeFiltersSummary = formatMunicipalityActiveFiltersSummary({
+ ...state,
+ q: draftQ,
+ })
+ const hasActiveFilters = Boolean(activeFiltersSummary)
useEffect(
() => () => {
@@ -56,19 +110,28 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
}
}
- const commitNavigation = (patch: Partial) => {
+ const navigateTo = (next: MunicipalityListState) => {
clearDebounce()
- const merged: MunicipalityListState = {
- ...state,
- ...patch,
+ const canonical: MunicipalityListState = {
+ ...next,
page: 1,
- q: normalizedText(patch.q !== undefined ? patch.q : search),
+ q: normalizedText(next.q),
}
- if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 }))
+ if (
+ buildMunicipalityFiltersKey(canonical) === buildMunicipalityFiltersKey({ ...state, page: 1 })
+ )
return
startTransition(() => {
- router.replace(buildMunicipalityListHref(merged, 1), { scroll: false })
+ router.replace(buildMunicipalityListHref(canonical, 1), { scroll: false })
+ })
+ }
+
+ const commitNavigation = (patch: Partial) => {
+ navigateTo({
+ ...state,
+ ...patch,
+ q: normalizedText(patch.q !== undefined ? patch.q : search),
})
}
@@ -82,15 +145,11 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
}, SEARCH_DEBOUNCE_MS)
}
- const hasActiveFilters = Boolean(
- state.q ||
- normalizedText(search) ||
- state.region ||
- state.kind ||
- state.coverage ||
- state.priority ||
- state.trend,
- )
+ const mobileFilterDefinitions = municipalityFilterDefinitions.filter((definition) => {
+ if (definition.staffOnly && !showStaffFilters) return false
+ // Header popovers own multi/name filters; mobile keeps exclusive selects.
+ return definition.selection === 'single' || definition.selection === 'toggle'
+ })
return (
+const Table = ({
+ className,
+ containerClassName,
+ ...props
+}: React.ComponentProps<'table'> & {
+ /** Escape hatch for the scroll container — e.g. `overflow-x-visible` so a
+ * sticky header can resolve against the page scroller instead of this box. */
+ containerClassName?: string
+}) => (
+
export const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
+/** Flatten a repeated query param into distinct trimmed values. */
+export const allParamValues = (value: string | string[] | undefined): string[] => {
+ if (value === undefined) return []
+ const cells = Array.isArray(value) ? value : [value]
+ const seen = new Set()
+ for (const cell of cells) {
+ const trimmed = cell.trim()
+ if (trimmed) seen.add(trimmed)
+ }
+ return [...seen]
+}
+
export const normalizedText = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined
const normalized = value.trim()
diff --git a/src/utilities/campaignMunicipalityScope.ts b/src/utilities/campaignMunicipalityScope.ts
index 0c23dd0e..02b4bd97 100644
--- a/src/utilities/campaignMunicipalityScope.ts
+++ b/src/utilities/campaignMunicipalityScope.ts
@@ -13,7 +13,15 @@ import {
/** Superset of the fields the dashboard, map, and list overview consume. */
export type ScopedMunicipalityDoc = Pick<
Municipality,
- 'id' | 'name' | 'slug' | 'kind' | 'ibgeCode' | 'advisors' | 'priority' | 'expectedVotes'
+ | 'id'
+ | 'name'
+ | 'slug'
+ | 'kind'
+ | 'region'
+ | 'ibgeCode'
+ | 'advisors'
+ | 'priority'
+ | 'expectedVotes'
>
export type MunicipalityScope = {
@@ -35,6 +43,7 @@ const loadScope = async (
name: true,
slug: true,
kind: true,
+ region: true,
ibgeCode: true,
advisors: true,
priority: true,
diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts
index ef33d79e..ffd355ea 100644
--- a/src/utilities/municipalityPageData.ts
+++ b/src/utilities/municipalityPageData.ts
@@ -1,6 +1,7 @@
-import type { Payload } from 'payload'
+import type { Payload, Where } from 'payload'
-import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
+import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
+import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
@@ -19,6 +20,7 @@ import {
resolveMunicipalityLastSignalAt,
resolveMunicipalityListSort,
type MunicipalityListSearchParams,
+ type MunicipalityListState,
type MunicipalityListSortDirection,
type MunicipalityListSortKey,
} from '@/utilities/municipalityUi'
@@ -62,12 +64,97 @@ export type MunicipalityListOverviewData = {
goalCoverageByScenario: Record
}
+/** Values still reachable under the OTHER active filters (column-filter options). */
+type MunicipalityListFilterFacets = {
+ /** Catalog order. */
+ slugs: string[]
+ regions: string[]
+ advisorIDs: number[]
+}
+
export type MunicipalityListPageBundle = {
municipalities: MunicipalityListViewModel[]
totalDocs: number
totalPages: number
scopeTotal: number
overview: MunicipalityListOverviewData | null
+ filterFacets: MunicipalityListFilterFacets
+}
+
+const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
+ slugs: [],
+ regions: [],
+ advisorIDs: [],
+}
+
+type MunicipalityFacetRow = Pick
+
+/**
+ * Each facet applies every OTHER active filter (so the popover only offers values
+ * that still return rows) while omitting its own param (so the user can keep
+ * adding to the OR set). Identical `where` shapes collapse into one read, and the
+ * already-loaded scope seeds the unfiltered shape — with no multi-select active
+ * (the common case) the facets cost no query at all.
+ */
+const loadMunicipalityListFilterFacets = async (
+ payload: Payload,
+ user: CampaignUser,
+ state: MunicipalityListState,
+ loadedScope: { where: Where; rows: MunicipalityFacetRow[] } | null,
+): Promise => {
+ const rowsByWhere = new Map>()
+ if (loadedScope) {
+ rowsByWhere.set(JSON.stringify(loadedScope.where), Promise.resolve(loadedScope.rows))
+ }
+ const facetRows = (omit: Partial): Promise => {
+ const where = buildMunicipalityListWhere({ ...state, ...omit })
+ const key = JSON.stringify(where)
+ const pending = rowsByWhere.get(key)
+ if (pending) return pending
+
+ const rows = payload
+ .find({
+ collection: 'municipality',
+ where,
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { slug: true, region: true, advisors: true },
+ user,
+ overrideAccess: false,
+ })
+ .then((result) => result.docs as MunicipalityFacetRow[])
+ rowsByWhere.set(key, rows)
+ return rows
+ }
+
+ const [slugRows, regionRows, advisorRows] = await Promise.all([
+ facetRows({ slugs: undefined }),
+ facetRows({ regions: undefined }),
+ facetRows({ advisors: undefined }),
+ ])
+
+ // Selected values are unioned in: a selection must stay visible to be undone.
+ const availableSlugs = new Set([...slugRows.map((row) => row.slug), ...(state.slugs ?? [])])
+ const availableRegions = new Set([
+ ...regionRows.map((row) => row.region),
+ ...(state.regions ?? []),
+ ])
+ const availableAdvisorIDs = new Set(state.advisors ?? [])
+ for (const row of advisorRows) {
+ for (const advisor of row.advisors ?? []) {
+ const id = relationshipId(advisor)
+ if (id !== null) availableAdvisorIDs.add(id)
+ }
+ }
+
+ return {
+ slugs: municipalityCatalog
+ .filter((entry) => availableSlugs.has(entry.slug))
+ .map((entry) => entry.slug),
+ regions: bahiaIdentityTerritories.filter((territory) => availableRegions.has(territory)),
+ advisorIDs: [...availableAdvisorIDs].sort((left, right) => left - right),
+ }
}
const payloadSortFieldByKey: Partial> = {
@@ -176,6 +263,7 @@ export const loadMunicipalityListPageBundle = async (
totalPages: 0,
scopeTotal: 0,
overview: null,
+ filterFacets: emptyMunicipalityListFilterFacets,
}
}
@@ -214,6 +302,13 @@ export const loadMunicipalityListPageBundle = async (
isStaff ? loadMunicipalityScope(payload, user, where) : Promise.resolve(null),
])
+ const filterFacets = await loadMunicipalityListFilterFacets(
+ payload,
+ user,
+ state,
+ staffScope ? { where, rows: staffScope.municipalities } : null,
+ )
+
let overview: MunicipalityListOverviewData | null = null
let pledgeAggregates = new Map()
let goalCoverageByMunicipalityID = new Map<
@@ -221,7 +316,9 @@ export const loadMunicipalityListPageBundle = async (
Record
>()
- if (staffScope && staffScope.municipalities.length > 0) {
+ // Computed even for an empty filtered scope: the overview stays on screen
+ // (zeroed) next to the empty state instead of disappearing with the rows.
+ if (staffScope) {
pledgeAggregates = staffScope.pledgeAggregates
const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates)
const goalCoverageBundle = await loadMunicipalityGoalCoverageBundle(
@@ -286,6 +383,7 @@ export const loadMunicipalityListPageBundle = async (
totalPages,
scopeTotal: scopeCount.totalDocs,
overview,
+ filterFacets,
}
}
diff --git a/src/utilities/municipalityUi.ts b/src/utilities/municipalityUi.ts
index 8981ab24..b9413ee1 100644
--- a/src/utilities/municipalityUi.ts
+++ b/src/utilities/municipalityUi.ts
@@ -5,6 +5,7 @@ import { formatElectionNumber } from '@/lib/electionInsights'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import {
+ allParamValues,
buildListHref,
firstValue,
normalizedText,
@@ -27,7 +28,9 @@ export const municipalityPriorityLabels: Record['status']>
+export type PoliticalTrendStatus = NonNullable<
+ NonNullable['status']
+>
export const politicalTrendLabels: Record = {
favoravel: 'Favorável',
@@ -67,13 +70,13 @@ export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection =
export const municipalityListSortLabels: Record = {
name: 'Município',
- region: 'Território de identidade',
+ region: 'Território',
kind: 'Tipo',
trend: 'Tendência',
expectedVotes: 'Votos estimados',
lastUpdateAt: 'Última atualização',
- /** Advisor coverage — "Cobertura" alone now reads as the goal one (`deficit`). */
- coverage: 'Assessoria',
+ /** Sorts the "Assessores" column — "Cobertura" alone now reads as the goal one (`deficit`). */
+ coverage: 'Assessores',
/** Short header — definition lives on hover (`formatMunicipalityConcentrationHint`). */
votos: '2022',
deficit: 'Cobertura da meta',
@@ -83,11 +86,17 @@ export const municipalityListSortLabels: Record
export type MunicipalityListState = {
page: number
q?: string
- region?: BahiaIdentityTerritory
+ /** Multi-select (OR) identity territories. */
+ regions?: BahiaIdentityTerritory[]
+ /** Multi-select (OR) catalog slugs. */
+ slugs?: string[]
+ /** Multi-select (OR) campaignUser advisor IDs. */
+ advisors?: number[]
kind?: Municipality['kind']
coverage?: 'com_assessor' | 'sem_assessor'
priority?: 'alta'
- trend?: PoliticalTrendStatus
+ /** Multi-select (OR) political trends. All or none selected means "todas". */
+ trends?: PoliticalTrendStatus[]
/** Candidate number for the map comparison mode (does not filter the list). */
compare?: number
sort?: MunicipalityListSortKey
@@ -99,6 +108,8 @@ export type MunicipalityListSearchParams = CampaignListRawSearchParams
export const municipalityListParamNames = [
'q',
'region',
+ 'slug',
+ 'advisor',
'kind',
'coverage',
'priority',
@@ -109,6 +120,11 @@ export const municipalityListParamNames = [
'page',
] as const
+const municipalitySlugSet = new Set(municipalityCatalog.map((entry) => entry.slug))
+const municipalityNameBySlug = new Map(
+ municipalityCatalog.map((entry) => [entry.slug, entry.name] as const),
+)
+
const municipalityListParamNameSet = new Set(municipalityListParamNames)
// Derived from the label record (total over the union), so a new sort key is
@@ -215,17 +231,84 @@ const canonicalTerritoryBySearchValue = new Map(
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
)
-export const parseMunicipalityListParams = (params: MunicipalityListSearchParams): MunicipalityListState => {
+const parseRegionsParam = (raw: string | string[] | undefined): BahiaIdentityTerritory[] => {
+ const regions: BahiaIdentityTerritory[] = []
+ const seen = new Set()
+ for (const token of allParamValues(raw)) {
+ const region = canonicalTerritoryBySearchValue.get(normalizeSearchPhrase(token))
+ if (!region || seen.has(region)) continue
+ seen.add(region)
+ regions.push(region)
+ }
+ return regions
+}
+
+const parseSlugsParam = (raw: string | string[] | undefined): string[] => {
+ const slugs: string[] = []
+ const seen = new Set()
+ for (const token of allParamValues(raw)) {
+ if (!municipalitySlugSet.has(token) || seen.has(token)) continue
+ seen.add(token)
+ slugs.push(token)
+ }
+ return slugs
+}
+
+const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
+ const advisors: number[] = []
+ const seen = new Set()
+ for (const token of allParamValues(raw)) {
+ const id = strictDecimalInteger(token)
+ if (id === undefined || seen.has(id)) continue
+ seen.add(id)
+ advisors.push(id)
+ }
+ return advisors.sort((left, right) => left - right)
+}
+
+const politicalTrendStatusSet = new Set(Object.keys(politicalTrendLabels))
+
+const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendStatus[] => {
+ const trends: PoliticalTrendStatus[] = []
+ for (const token of allParamValues(raw)) {
+ if (!politicalTrendStatusSet.has(token)) continue
+ const trend = token as PoliticalTrendStatus
+ if (trends.includes(trend)) continue
+ trends.push(trend)
+ }
+ return trends
+}
+
+const municipalityListStateToRawParams = (
+ state: MunicipalityListState,
+ page = state.page,
+): MunicipalityListSearchParams => ({
+ page: String(page),
+ q: state.q,
+ region: state.regions,
+ slug: state.slugs,
+ advisor: state.advisors?.map(String),
+ kind: state.kind,
+ coverage: state.coverage,
+ priority: state.priority,
+ trend: state.trends,
+ compare: state.compare === undefined ? undefined : String(state.compare),
+ sort: state.sort,
+ dir: state.dir,
+})
+
+export const parseMunicipalityListParams = (
+ params: MunicipalityListSearchParams,
+): MunicipalityListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
- const rawRegion = normalizedText(firstValue(params.region))
- const region = rawRegion
- ? canonicalTerritoryBySearchValue.get(normalizeSearchPhrase(rawRegion))
- : undefined
+ const regions = parseRegionsParam(params.region)
+ const slugs = parseSlugsParam(params.slug)
+ const advisors = parseAdvisorsParam(params.advisor)
const rawKind = firstValue(params.kind)
const rawCoverage = firstValue(params.coverage)
const rawPriority = firstValue(params.priority)
- const rawTrend = firstValue(params.trend)
+ const trends = parseTrendsParam(params.trend)
const rawCompare = strictDecimalInteger(firstValue(params.compare))
const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined
const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined
@@ -235,15 +318,15 @@ export const parseMunicipalityListParams = (params: MunicipalityListSearchParams
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
- ...(region ? { region } : {}),
+ ...(regions.length ? { regions } : {}),
+ ...(slugs.length ? { slugs } : {}),
+ ...(advisors.length ? { advisors } : {}),
...(rawKind === 'municipio' || rawKind === 'zona' ? { kind: rawKind } : {}),
...(rawCoverage === 'com_assessor' || rawCoverage === 'sem_assessor'
? { coverage: rawCoverage }
: {}),
...(rawPriority === 'alta' ? { priority: 'alta' } : {}),
- ...(rawTrend === 'favoravel' || rawTrend === 'neutra' || rawTrend === 'desfavoravel'
- ? { trend: rawTrend }
- : {}),
+ ...(trends.length ? { trends } : {}),
...(rawCompare && rawCompare <= 99999 ? { compare: rawCompare } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
@@ -261,7 +344,9 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
}
filters.push({ or: searchFilters })
}
- if (state.region) filters.push({ region: { equals: state.region } })
+ if (state.regions?.length) filters.push({ region: { in: state.regions } })
+ if (state.slugs?.length) filters.push({ slug: { in: state.slugs } })
+ if (state.advisors?.length) filters.push({ advisors: { in: state.advisors } })
if (state.kind) filters.push({ kind: { equals: state.kind } })
if (state.coverage) {
filters.push({
@@ -269,9 +354,8 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
})
}
if (state.priority) filters.push({ priority: { equals: state.priority } })
- if (state.trend) {
- filters.push({ 'politicalTrend.status': { equals: state.trend } })
- }
+ const trends = narrowingMunicipalityTrends(state)
+ if (trends.length) filters.push({ 'politicalTrend.status': { in: trends } })
return filters.length ? { and: filters } : {}
}
@@ -280,18 +364,7 @@ export const buildMunicipalityListSearchParams = (
state: MunicipalityListState,
page = state.page,
): URLSearchParams => {
- const canonicalState = parseMunicipalityListParams({
- page: String(page),
- q: state.q,
- region: state.region,
- kind: state.kind,
- coverage: state.coverage,
- priority: state.priority,
- trend: state.trend,
- compare: state.compare === undefined ? undefined : String(state.compare),
- sort: state.sort,
- dir: state.dir,
- })
+ const canonicalState = parseMunicipalityListParams(municipalityListStateToRawParams(state, page))
const params = new URLSearchParams()
const resolvedSort = canonicalState.sort ?? DEFAULT_MUNICIPALITY_LIST_SORT_KEY
const resolvedDir = canonicalState.dir ?? defaultMunicipalityListSortDir(resolvedSort)
@@ -300,11 +373,13 @@ export const buildMunicipalityListSearchParams = (
resolvedDir === defaultMunicipalityListSortDir(DEFAULT_MUNICIPALITY_LIST_SORT_KEY)
if (canonicalState.q) params.set('q', canonicalState.q)
- if (canonicalState.region) params.set('region', canonicalState.region)
+ for (const region of canonicalState.regions ?? []) params.append('region', region)
+ for (const slug of canonicalState.slugs ?? []) params.append('slug', slug)
+ for (const advisor of canonicalState.advisors ?? []) params.append('advisor', String(advisor))
if (canonicalState.kind) params.set('kind', canonicalState.kind)
if (canonicalState.coverage) params.set('coverage', canonicalState.coverage)
if (canonicalState.priority) params.set('priority', canonicalState.priority)
- if (canonicalState.trend) params.set('trend', canonicalState.trend)
+ for (const trend of canonicalState.trends ?? []) params.append('trend', trend)
if (canonicalState.compare) params.set('compare', String(canonicalState.compare))
// Omit the mesa default pair (votos+desc). Keep `sort` whenever the pair is
// non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
@@ -412,26 +487,257 @@ export const resolveMunicipalityListUrl = (
totalPages,
})
-export const municipalityListCoverageLabels: Record, string> = {
+export const municipalityListCoverageLabels: Record<
+ NonNullable,
+ string
+> = {
com_assessor: 'Com assessor',
sem_assessor: 'Sem assessor',
}
+/** Column-header filter affordances (B16+). `name` = Município (priority + slugs). */
+export type MunicipalityFilterParam = 'name' | 'region' | 'kind' | 'coverage' | 'trend' | 'advisor'
+
+type MunicipalityFilterSelectionMode = 'single' | 'multi' | 'toggle'
+
+export type MunicipalityFilterOption = {
+ value: string
+ label: string
+}
+
+/** Catalog labels for a (possibly cross-filtered) slug list. */
+export const municipalityFilterOptionsForSlugs = (
+ slugs: readonly string[],
+): MunicipalityFilterOption[] =>
+ slugs.map((slug) => ({ value: slug, label: municipalityNameBySlug.get(slug) ?? slug }))
+
+export type MunicipalityFilterDefinition = {
+ param: MunicipalityFilterParam
+ label: string
+ /** Shown for exclusive single-select clear row; omitted for toggle/multi. */
+ allLabel?: string
+ staffOnly: boolean
+ selection: MunicipalityFilterSelectionMode
+ /** Absent for the params whose options are cross-filtered server-side. */
+ options?: MunicipalityFilterOption[]
+}
+
+export const municipalityFilterDefinitions: MunicipalityFilterDefinition[] = [
+ {
+ param: 'name',
+ label: 'Município',
+ staffOnly: false,
+ selection: 'multi',
+ },
+ {
+ param: 'region',
+ label: 'Território',
+ staffOnly: false,
+ selection: 'multi',
+ },
+ {
+ param: 'kind',
+ label: 'Tipo',
+ allLabel: 'Todos',
+ staffOnly: false,
+ selection: 'single',
+ options: (
+ Object.keys(municipalityKindLabels) as Array
+ ).map((kind) => ({ value: kind, label: municipalityKindLabels[kind] })),
+ },
+ {
+ param: 'advisor',
+ label: 'Assessores',
+ staffOnly: true,
+ selection: 'multi',
+ },
+ {
+ param: 'coverage',
+ label: 'Assessoria',
+ staffOnly: true,
+ selection: 'toggle',
+ options: (
+ Object.keys(municipalityListCoverageLabels) as Array<
+ keyof typeof municipalityListCoverageLabels
+ >
+ ).map((coverage) => ({ value: coverage, label: municipalityListCoverageLabels[coverage] })),
+ },
+ {
+ param: 'trend',
+ label: 'Tendência',
+ staffOnly: true,
+ selection: 'multi',
+ options: (Object.keys(politicalTrendLabels) as Array).map(
+ (trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
+ ),
+ },
+]
+
+const municipalityFilterDefinitionByParam = Object.fromEntries(
+ municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
+) as Record
+
+export const getMunicipalityFilterDefinition = (
+ param: MunicipalityFilterParam,
+): MunicipalityFilterDefinition => municipalityFilterDefinitionByParam[param]
+
+export const getMunicipalitySingleFilterValue = (
+ state: MunicipalityListState,
+ param: 'kind' | 'coverage',
+): string | undefined => state[param]
+
+export type MunicipalityMultiFilterParam = 'region' | 'slug' | 'advisor' | 'trend'
+
+export const getMunicipalityMultiFilterValues = (
+ state: MunicipalityListState,
+ param: MunicipalityMultiFilterParam,
+): string[] => {
+ if (param === 'region') return state.regions ?? []
+ if (param === 'slug') return state.slugs ?? []
+ if (param === 'trend') return state.trends ?? []
+ return (state.advisors ?? []).map(String)
+}
+
+const withMunicipalityListPageReset = (state: MunicipalityListState): MunicipalityListState =>
+ parseMunicipalityListParams(municipalityListStateToRawParams({ ...state, page: 1 }, 1))
+
+/** Exclusive single-value set/clear for `kind`. Empty (or invalid) clears. */
+export const applyMunicipalityKindFilter = (
+ state: MunicipalityListState,
+ value: string | undefined,
+): MunicipalityListState =>
+ parseMunicipalityListParams({
+ ...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
+ kind: value || undefined,
+ })
+
+/** Toggle exclusivity: clicking the active value clears (coverage). */
+export const toggleMunicipalityExclusiveFilterValue = (
+ state: MunicipalityListState,
+ param: 'coverage',
+ value: string,
+): MunicipalityListState =>
+ withMunicipalityListPageReset({
+ ...state,
+ [param]: state[param] === value ? undefined : (value as MunicipalityListState['coverage']),
+ })
+
+export const toggleMunicipalityPriorityFilter = (
+ state: MunicipalityListState,
+): MunicipalityListState =>
+ withMunicipalityListPageReset({
+ ...state,
+ priority: state.priority === 'alta' ? undefined : 'alta',
+ })
+
+/**
+ * Writes the RAW param and lets `parseMunicipalityListParams` validate, so no
+ * branch has to cast a `string[]` into the state's narrow value types.
+ */
+const setMunicipalityMultiFilterValues = (
+ state: MunicipalityListState,
+ param: MunicipalityMultiFilterParam,
+ values: string[],
+): MunicipalityListState =>
+ parseMunicipalityListParams({
+ ...municipalityListStateToRawParams({ ...state, page: 1 }, 1),
+ [param]: values,
+ })
+
+export const toggleMunicipalityMultiFilterValue = (
+ state: MunicipalityListState,
+ param: MunicipalityMultiFilterParam,
+ value: string,
+): MunicipalityListState => {
+ const current = getMunicipalityMultiFilterValues(state, param)
+ return setMunicipalityMultiFilterValues(
+ state,
+ param,
+ current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
+ )
+}
+
+export const clearMunicipalityMultiFilter = (
+ state: MunicipalityListState,
+ param: MunicipalityMultiFilterParam,
+): MunicipalityListState => setMunicipalityMultiFilterValues(state, param, [])
+
+/** Clear municipality-column filters (priority + selected slugs). */
+export const clearMunicipalityNameFilters = (state: MunicipalityListState): MunicipalityListState =>
+ withMunicipalityListPageReset({ ...state, priority: undefined, slugs: undefined })
+
+/** Clear advisor-column filters (com/sem assessor + selected advisors). */
+export const clearMunicipalityAdvisorFilters = (
+ state: MunicipalityListState,
+): MunicipalityListState =>
+ withMunicipalityListPageReset({ ...state, coverage: undefined, advisors: undefined })
+
+export const buildMunicipalityFilterHref = (next: MunicipalityListState): string =>
+ buildMunicipalityListHref(next, 1)
+
+export const isMunicipalityColumnFilterActive = (
+ state: MunicipalityListState,
+ param: MunicipalityFilterParam,
+): boolean => {
+ switch (param) {
+ case 'name':
+ return Boolean(state.priority || state.slugs?.length)
+ case 'region':
+ return Boolean(state.regions?.length)
+ case 'advisor':
+ // The Assessores popover also owns the com/sem assessor toggle.
+ return Boolean(state.advisors?.length || state.coverage)
+ case 'kind':
+ return Boolean(state.kind)
+ case 'coverage':
+ return Boolean(state.coverage)
+ case 'trend':
+ return narrowingMunicipalityTrends(state).length > 0
+ }
+}
+
+/** All (or no) trends selected means "todas", so neither narrows the list. */
+const narrowingMunicipalityTrends = (state: MunicipalityListState): PoliticalTrendStatus[] => {
+ const trends = state.trends ?? []
+ return trends.length < politicalTrendStatusSet.size ? trends : []
+}
+
+/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
+const firstNamesLabel = (names: string[]): string =>
+ names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
+
+export const formatMunicipalityActiveFiltersSummary = (
+ state: MunicipalityListState,
+): string | null => {
+ const parts: string[] = []
+ if (state.priority) parts.push(municipalityPriorityLabels.alta)
+ if (state.slugs?.length) {
+ parts.push(firstNamesLabel(state.slugs.map((slug) => municipalityNameBySlug.get(slug) ?? slug)))
+ }
+ if (state.regions?.length) parts.push(firstNamesLabel([...state.regions]))
+ if (state.kind) parts.push(municipalityKindLabels[state.kind])
+ if (state.advisors?.length) {
+ parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
+ }
+ if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
+ const trends = narrowingMunicipalityTrends(state)
+ if (trends.length) {
+ parts.push(
+ `Tendência ${trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
+ )
+ }
+ if (state.q) parts.push(`Busca "${state.q}"`)
+
+ return parts.length ? parts.join(' · ') : null
+}
+
const MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH = 80
export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): string | null => {
- const parts: string[] = []
+ const summary = formatMunicipalityActiveFiltersSummary(state)
+ if (!summary) return null
- if (state.region) parts.push(state.region)
- if (state.kind) parts.push(municipalityKindLabels[state.kind])
- if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
- if (state.priority) parts.push(municipalityPriorityLabels.alta)
- if (state.trend) parts.push(`Tendência ${politicalTrendLabels[state.trend].toLowerCase()}`)
- if (state.q) parts.push(`Busca "${state.q}"`)
-
- if (!parts.length) return null
-
- const label = `Municípios · ${parts.join(' · ')}`
+ const label = `Municípios · ${summary}`
if (label.length <= MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH) return label
return `${label.slice(0, MAX_MUNICIPALITY_LIST_VISIT_LABEL_LENGTH - 1)}…`
}
@@ -439,7 +745,10 @@ export const buildMunicipalityListVisitLabel = (state: MunicipalityListState): s
export const buildMunicipalityListVisitHref = (state: MunicipalityListState): string =>
buildMunicipalityListHref(state, 1)
-export const getCampaignScopeLabel = (role: CampaignUser['role'], municipalityCount: number): string => {
+export const getCampaignScopeLabel = (
+ role: CampaignUser['role'],
+ municipalityCount: number,
+): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
diff --git a/tests/int/municipalityPageData.int.spec.ts b/tests/int/municipalityPageData.int.spec.ts
index 033c1406..bec5eb73 100644
--- a/tests/int/municipalityPageData.int.spec.ts
+++ b/tests/int/municipalityPageData.int.spec.ts
@@ -4,6 +4,7 @@ import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
+import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import { loadMunicipalityListPageBundle } from '@/utilities/municipalityPageData'
import {
@@ -26,13 +27,17 @@ describe('loadMunicipalityListPageBundle', () => {
payload = await getPayload({ config: await config })
})
- it('returns null overview when the filtered municipality set is empty', async () => {
+ it('keeps a zeroed overview when the filtered municipality set is empty', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: 'zzznomatch' })
+ // The overview stays on screen next to the empty state instead of unmounting.
expect(bundle.municipalities).toHaveLength(0)
- expect(bundle.overview).toBeNull()
+ expect(bundle.overview).not.toBeNull()
+ expect(bundle.overview!.municipalityCount).toBe(0)
+ expect(bundle.overview!.staffVoteTotalByScenario.central).toBe(0)
+ expect(bundle.overview!.goalCoverageByScenario.central.goal).toBe(0)
})
it('rolls up staffVoteTotal from expectedVotes when no pledge overrides apply', async () => {
@@ -290,10 +295,41 @@ describe('loadMunicipalityListPageBundle', () => {
administered.kind === 'zona'
? await loadMunicipalityListPageBundle(payload, advisor, { kind: 'municipio' })
: await loadMunicipalityListPageBundle(payload, advisor, { kind: 'zona' })
- expect(excluded.overview).toBeNull()
+ expect(excluded.overview?.municipalityCount).toBe(0)
expect(excluded.municipalities).toHaveLength(0)
})
+ it('cross-filters column-filter options but never narrows a filter by itself', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const municipality = await fixtures.getMunicipality()
+ const catalogEntry = getMunicipalityCatalogEntry(municipality.slug)
+ expect(catalogEntry).toBeDefined()
+ const outsideRegion = municipalityCatalog.find(
+ (entry) => entry.region !== catalogEntry!.region,
+ )!
+
+ const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
+ region: catalogEntry!.region,
+ })
+
+ // The território filter narrows the Município options…
+ expect(bundle.filterFacets.slugs).toContain(municipality.slug)
+ expect(bundle.filterFacets.slugs).not.toContain(outsideRegion.slug)
+ expect(bundle.filterFacets.slugs.length).toBeLessThan(municipalityCatalog.length)
+ // …but not its own: the OR set must stay addable.
+ expect(bundle.filterFacets.regions.length).toBeGreaterThan(1)
+
+ // A selected value stays listed even when other filters exclude it, so it
+ // can be undone from the popover.
+ const conflicting = await loadMunicipalityListPageBundle(payload, coordinator, {
+ region: catalogEntry!.region,
+ slug: outsideRegion.slug,
+ })
+ expect(conflicting.municipalities).toHaveLength(0)
+ expect(conflicting.filterFacets.slugs).toContain(outsideRegion.slug)
+ })
+
it('returns an empty bundle for leaders (municipality list lockdown)', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts
index ec0fa5b4..40a42fb6 100644
--- a/tests/unit/campaignComponents.unit.spec.ts
+++ b/tests/unit/campaignComponents.unit.spec.ts
@@ -15,7 +15,10 @@ import { Toggle } from '@/components/ui/Toggle'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { createEmptyGoalCoverageByScenario } from '@/utilities/goalCoverage'
-import type { MunicipalityAdvisorSummary, MunicipalityListViewModel } from '@/utilities/municipalityViewModels'
+import type {
+ MunicipalityAdvisorSummary,
+ MunicipalityListViewModel,
+} from '@/utilities/municipalityViewModels'
import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
@@ -36,6 +39,7 @@ const noopListFormAction = async (
const municipalityListDefaultProps = {
isCoordinator: false,
advisorOptions: [],
+ columnFilterOptions: { name: [], region: [], advisor: [] },
trendFormAction: noopListFormAction,
advisorsFormAction: noopListFormAction,
state: { page: 1 },
@@ -220,8 +224,10 @@ describe('campaign visual foundation', () => {
expect(html).toContain('1.500')
expect(html).toContain('Cenários de estimativa')
expect(html).toContain('Prioritária')
- expect(html).toContain('Coberta')
- expect(html).toContain('Sem assessor')
+ // The advisor column carries coverage on its own — no separate "Assessoria" column.
+ expect(html).toContain('Assessores')
+ expect(html).toContain('Ana Bastos')
+ expect(html).not.toContain('Assessoria')
expect(html).toContain('Tendência')
expect(html).toContain('Cobertura da meta')
expect(html).toContain('80%')
diff --git a/tests/unit/municipalityUi.unit.spec.ts b/tests/unit/municipalityUi.unit.spec.ts
index 78dabd2b..ef628e5a 100644
--- a/tests/unit/municipalityUi.unit.spec.ts
+++ b/tests/unit/municipalityUi.unit.spec.ts
@@ -1,13 +1,20 @@
import { describe, expect, it } from 'vitest'
+import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
+ applyMunicipalityKindFilter,
+ buildMunicipalityFilterHref,
buildMunicipalityListHref,
+ buildMunicipalityListWhere,
buildMunicipalitySortHref,
+ formatMunicipalityActiveFiltersSummary,
formatMunicipalityConcentrationHint,
formatMunicipalityListSortSummary,
formatMunicipalitySignalAgeLabel,
+ isMunicipalityColumnFilterActive,
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
+ municipalityListSortLabels,
municipalitySignalAgeInDays,
parseMunicipalityListParams,
parseMunicipalitySortValue,
@@ -15,6 +22,9 @@ import {
resolveMunicipalityListSort,
serializeMunicipalitySortValue,
shouldUpdateMunicipalitySearchUrl,
+ toggleMunicipalityExclusiveFilterValue,
+ toggleMunicipalityMultiFilterValue,
+ toggleMunicipalityPriorityFilter,
} from '@/utilities/municipalityUi'
describe('shouldUpdateMunicipalitySearchUrl', () => {
@@ -95,9 +105,7 @@ describe('municipality list sort params (A11 + B15 + E9)', () => {
expect(formatMunicipalityListSortSummary('votos', 'desc')).toBe('Ordenado por 2022 ↓')
expect(formatMunicipalityListSortSummary('votos', 'asc')).toBe('Ordenado por 2022 ↑')
expect(formatMunicipalityListSortSummary('name', 'asc')).toBe('Ordenado por nome (A–Z)')
- expect(formatMunicipalityListSortSummary('region', 'asc')).toBe(
- 'Ordenado por Território de identidade ↑',
- )
+ expect(formatMunicipalityListSortSummary('region', 'asc')).toBe('Ordenado por Território ↑')
expect(formatMunicipalityListSortSummary('deficit', 'desc')).toBe(
'Ordenado por déficit da meta (maior primeiro)',
)
@@ -112,6 +120,110 @@ describe('municipality list sort params (A11 + B15 + E9)', () => {
expect(formatMunicipalityConcentrationHint()).not.toMatch(/^%/)
expect(formatMunicipalityConcentrationHint(12)).toMatch(/12/)
})
+
+ it('labels the advisor-count sort as Assessores (not Cobertura da meta)', () => {
+ expect(municipalityListSortLabels.coverage).toBe('Assessores')
+ expect(formatMunicipalityListSortSummary('coverage', 'asc')).toBe('Ordenado por Assessores ↑')
+ })
+})
+
+describe('municipality list header filters (B16+)', () => {
+ it('toggles multi region OR filters and preserves sort/dir', () => {
+ const state = parseMunicipalityListParams({
+ page: '3',
+ sort: 'votos',
+ dir: 'asc',
+ kind: 'zona',
+ })
+ const withRegion = toggleMunicipalityMultiFilterValue(state, 'region', 'Irecê')
+ expect(withRegion.regions).toEqual(['Irecê'])
+ expect(withRegion.page).toBe(1)
+ expect(buildMunicipalityFilterHref(withRegion)).toBe(
+ '/campanha/municipios?region=Irec%C3%AA&kind=zona&sort=votos&dir=asc',
+ )
+
+ const withTwo = toggleMunicipalityMultiFilterValue(withRegion, 'region', 'Recôncavo')
+ expect(withTwo.regions).toEqual(['Irecê', 'Recôncavo'])
+ expect(buildMunicipalityListHref(withTwo, 1)).toContain('region=Irec')
+ expect(buildMunicipalityListHref(withTwo, 1)).toContain('region=Rec')
+
+ const cleared = toggleMunicipalityMultiFilterValue(withRegion, 'region', 'Irecê')
+ expect(cleared.regions).toBeUndefined()
+ })
+
+ it('toggles the Prioritária filter in and out of the URL, keeping other filters', () => {
+ const state = parseMunicipalityListParams({ region: 'Irecê' })
+
+ const on = toggleMunicipalityPriorityFilter(state)
+ expect(on.priority).toBe('alta')
+ expect(buildMunicipalityFilterHref(on)).toBe(
+ '/campanha/municipios?region=Irec%C3%AA&priority=alta',
+ )
+
+ const off = toggleMunicipalityPriorityFilter(on)
+ expect(off.priority).toBeUndefined()
+ expect(buildMunicipalityFilterHref(off)).toBe('/campanha/municipios?region=Irec%C3%AA')
+ })
+
+ it('treats every trend selected as "todas" (no narrowing constraint)', () => {
+ const one = parseMunicipalityListParams({ trend: 'favoravel' })
+ expect(one.trends).toEqual(['favoravel'])
+ expect(isMunicipalityColumnFilterActive(one, 'trend')).toBe(true)
+ expect(buildMunicipalityListWhere(one)).toEqual({
+ and: [{ 'politicalTrend.status': { in: ['favoravel'] } }],
+ })
+
+ const all = parseMunicipalityListParams({ trend: ['favoravel', 'neutra', 'desfavoravel'] })
+ expect(isMunicipalityColumnFilterActive(all, 'trend')).toBe(false)
+ expect(buildMunicipalityListWhere(all)).toEqual({})
+ expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull()
+
+ const none = toggleMunicipalityMultiFilterValue(one, 'trend', 'favoravel')
+ expect(none.trends).toBeUndefined()
+ expect(buildMunicipalityFilterHref(none)).toBe('/campanha/municipios')
+ })
+
+ it('toggles coverage by re-clicking the active value', () => {
+ const state = parseMunicipalityListParams({})
+ const on = toggleMunicipalityExclusiveFilterValue(state, 'coverage', 'com_assessor')
+ expect(on.coverage).toBe('com_assessor')
+ const off = toggleMunicipalityExclusiveFilterValue(on, 'coverage', 'com_assessor')
+ expect(off.coverage).toBeUndefined()
+ })
+
+ it('discards invalid filter values', () => {
+ const state = parseMunicipalityListParams({})
+ expect(
+ toggleMunicipalityMultiFilterValue(state, 'region', 'NotATerritory').regions,
+ ).toBeUndefined()
+ expect(applyMunicipalityKindFilter(state, 'bairro').kind).toBeUndefined()
+ expect(
+ toggleMunicipalityExclusiveFilterValue(state, 'coverage', 'maybe').coverage,
+ ).toBeUndefined()
+ expect(toggleMunicipalityMultiFilterValue(state, 'trend', 'alta').trends).toBeUndefined()
+ expect(parseMunicipalityListParams({ slug: 'not-a-slug', advisor: 'x' }).slugs).toBeUndefined()
+ expect(parseMunicipalityListParams({ advisor: 'x' }).advisors).toBeUndefined()
+ })
+
+ it('parses repeated advisor and slug params as OR filters', () => {
+ const slug = municipalityCatalog[0]?.slug
+ expect(slug).toBeTruthy()
+ const state = parseMunicipalityListParams({
+ slug: [slug!, 'also-invalid'],
+ advisor: ['12', '12', '7'],
+ })
+ expect(state.slugs).toEqual([slug])
+ expect(state.advisors).toEqual([7, 12])
+ })
+
+ it('summarizes active filters for the slim bar', () => {
+ expect(formatMunicipalityActiveFiltersSummary(parseMunicipalityListParams({}))).toBeNull()
+ expect(
+ formatMunicipalityActiveFiltersSummary(
+ parseMunicipalityListParams({ region: 'Irecê', priority: 'alta', q: 'feira' }),
+ ),
+ ).toBe('Prioritária · Irecê · Busca "feira"')
+ })
})
describe('E9 frescor do sinal', () => {