From 96bf3624219950bb7d4dee36d436313dce5e3058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 03:01:49 -0300 Subject: [PATCH 1/2] =?UTF-8?q?B16:=20Filtros=20no=20header=20da=20lista?= =?UTF-8?q?=20de=20munic=C3=ADpios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popover de filtro por coluna no desktop (multi-seleção OR em município, território, assessores e tendência; busca acento-insensível e "Limpar"), mantendo os selects empilhados no mobile. Header sticky com a página como scroller e empty state que substitui só as linhas de conteúdo, preservando overview e filtros. Remove a coluna "Assessoria": a ordenação foi para "Assessores" e o com/sem assessor virou opção do mesmo popover. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 3 +- ...omponents-campaign-municipalitylist-tsx.md | 41 ++ .../filtros-no-header-lista-municipios.md | 138 +++--- docs/roadmap.md | 13 +- .../campanha/(app)/municipios/page.tsx | 64 +-- .../campaign/CampaignListPending.tsx | 9 +- .../campaign/MunicipalityFilters.tsx | 356 +++++++++------- .../campaign/MunicipalityHeaderFilter.tsx | 316 ++++++++++++++ src/components/campaign/MunicipalityList.tsx | 143 +++++-- .../campaign/MunicipalitySortableHead.tsx | 55 ++- src/components/ui/Table.tsx | 15 +- src/utilities/campaignListUrl.ts | 12 + src/utilities/campaignMunicipalityScope.ts | 11 +- src/utilities/municipalityPageData.ts | 104 ++++- src/utilities/municipalityUi.ts | 403 ++++++++++++++++-- tests/int/municipalityPageData.int.spec.ts | 42 +- tests/unit/campaignComponents.unit.spec.ts | 12 +- tests/unit/municipalityUi.unit.spec.ts | 118 ++++- 18 files changed, 1484 insertions(+), 371 deletions(-) create mode 100644 .impeccable/critique/2026-07-25T02-37-03Z__src-components-campaign-municipalitylist-tsx.md create mode 100644 src/components/campaign/MunicipalityHeaderFilter.tsx 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 (
{isPending ? 'Atualizando resultados…' : ''}

+
- - Território de identidade - - commitNavigation({ - region: (event.target.value || undefined) as MunicipalityListState['region'], - }) - } - className="min-h-11 w-full" + {activeFiltersSummary ? ( +

- Todos - {bahiaIdentityTerritories.map((territory) => ( - - {territory} - - ))} - - - - Tipo - - commitNavigation({ - kind: (event.target.value || undefined) as MunicipalityListState['kind'], - }) - } - className="min-h-11 w-full" - > - Todos - {( - Object.keys(municipalityKindLabels) as Array - ).map((kind) => ( - - {municipalityKindLabels[kind]} - - ))} - - - {showStaffFilters ? ( - <> - - Assessoria - - commitNavigation({ - coverage: (event.target.value || - undefined) as MunicipalityListState['coverage'], - }) - } - className="min-h-11 w-full" - > - Todas - {( - Object.keys(municipalityListCoverageLabels) as Array< - keyof typeof municipalityListCoverageLabels - > - ).map((coverage) => ( - - {municipalityListCoverageLabels[coverage]} - - ))} - - - - Tendência - - commitNavigation({ - trend: (event.target.value || undefined) as MunicipalityListState['trend'], - }) - } - className="min-h-11 w-full" - > - Todas - {( - Object.keys(politicalTrendLabels) as Array - ).map((trend) => ( - - {politicalTrendLabels[trend]} - - ))} - - - - Prioridade - - commitNavigation({ - priority: event.target.value === 'alta' ? 'alta' : undefined, - }) - } - className="min-h-11 w-full" - > - Todas - Prioritárias - - - + {activeFiltersSummary} +

) : null} - - Ordenar - { - const parsed = parseMunicipalitySortValue(event.target.value) - if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir }) - }} - className="min-h-11 w-full" - > - {municipalityListSortOptions.map(({ key, dir, label }) => ( - - {label} - - ))} - - {hasActiveFilters ? (
+ +
+ {showStaffFilters ? ( + + Prioridade + { + navigateTo({ + ...state, + priority: event.target.value === 'alta' ? 'alta' : undefined, + q: normalizedText(search), + }) + }} + className="min-h-11 w-full" + > + Todas + Prioritária + + + ) : null} + {mobileFilterDefinitions.map((definition) => { + const value = + definition.param === 'kind' || definition.param === 'coverage' + ? (getMunicipalitySingleFilterValue(state, definition.param) ?? '') + : '' + return ( + + + {definition.label} + + { + const selected = event.target.value + const withSearch = { ...state, q: normalizedText(search) } + if (definition.selection === 'toggle') { + navigateTo( + selected + ? toggleMunicipalityExclusiveFilterValue(withSearch, 'coverage', selected) + : { ...withSearch, coverage: undefined, page: 1 }, + ) + return + } + if (definition.param === 'kind') { + navigateTo(applyMunicipalityKindFilter(withSearch, selected)) + } + }} + className="min-h-11 w-full" + > + {definition.allLabel ?? 'Todas'} + {(definition.options ?? []).map((option) => ( + + {option.label} + + ))} + + + ) + })} + {showStaffFilters ? ( + + navigateTo( + toggleMunicipalityMultiFilterValue( + { ...state, q: normalizedText(search) }, + 'trend', + value, + ), + ) + } + /> + ) : null} + {showStaffFilters && advisorFilterOptions.length ? ( + + navigateTo( + toggleMunicipalityMultiFilterValue( + { ...state, q: normalizedText(search) }, + 'advisor', + value, + ), + ) + } + /> + ) : null} + + Ordenar + { + const parsed = parseMunicipalitySortValue(event.target.value) + if (parsed) commitNavigation({ sort: parsed.key, dir: parsed.dir }) + }} + className="min-h-11 w-full" + > + {municipalityListSortOptions.map(({ key, dir, label }) => ( + + {label} + + ))} + + +
) } diff --git a/src/components/campaign/MunicipalityHeaderFilter.tsx b/src/components/campaign/MunicipalityHeaderFilter.tsx new file mode 100644 index 00000000..0ccd8a88 --- /dev/null +++ b/src/components/campaign/MunicipalityHeaderFilter.tsx @@ -0,0 +1,316 @@ +'use client' + +import { FunnelIcon, FunnelPlusIcon } from 'lucide-react' +import { useMemo, useState, type ReactNode } from 'react' + +import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending' +import { Checkbox } from '@/components/ui/Checkbox' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' +import { cn } from '@/lib/utils' +import { + applyMunicipalityKindFilter, + buildMunicipalityFilterHref, + buildMunicipalityFiltersKey, + clearMunicipalityAdvisorFilters, + clearMunicipalityMultiFilter, + clearMunicipalityNameFilters, + getMunicipalityFilterDefinition, + getMunicipalityMultiFilterValues, + getMunicipalitySingleFilterValue, + isMunicipalityColumnFilterActive, + municipalityPriorityLabels, + toggleMunicipalityExclusiveFilterValue, + toggleMunicipalityMultiFilterValue, + toggleMunicipalityPriorityFilter, + type MunicipalityFilterOption, + type MunicipalityFilterParam, + type MunicipalityListState, + type MunicipalityMultiFilterParam, +} from '@/utilities/municipalityUi' +import { normalizeSearchPhrase } from '@/utilities/wordStartFilter' + +type MunicipalityHeaderFilterProps = { + state: MunicipalityListState + filterParam: MunicipalityFilterParam + /** Options already narrowed by the other active filters (server-computed). */ + options?: MunicipalityFilterOption[] + /** Staff-only Prioritária checkbox inside the Município filter. */ + showPriorityFilter?: boolean +} + +/** Above this many options the popover gets a search box and a Limpar action. */ +const SEARCHABLE_OPTION_THRESHOLD = 8 + +const multiParamByFilter = { + name: 'slug', + region: 'region', + advisor: 'advisor', + trend: 'trend', + kind: null, + coverage: null, +} as const satisfies Record + +const FilterOptionLink = ({ + href, + selected, + checkbox = false, + onChoose, + children, +}: { + href: string + selected: boolean + checkbox?: boolean + onChoose: () => void + children: ReactNode +}) => ( + + {checkbox ? ( + <> + +) + +export const MunicipalityHeaderFilter = ({ + state, + filterParam, + options: optionsOverride, + showPriorityFilter = false, +}: MunicipalityHeaderFilterProps) => { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + /** + * Optimistic selection, tagged with the URL state it was derived from: when the + * server answer lands the tag stops matching and the truth takes over — no + * effect racing the transition (and no reverting a still-pending click). + */ + const [optimistic, setOptimistic] = useState<{ + baseKey: string + next: MunicipalityListState + } | null>(null) + + const definition = getMunicipalityFilterDefinition(filterParam) + const definitionOptions = definition.options + const options = useMemo( + () => optionsOverride ?? definitionOptions ?? [], + [optionsOverride, definitionOptions], + ) + const stateKey = buildMunicipalityFiltersKey(state) + const viewState = optimistic?.baseKey === stateKey ? optimistic.next : state + const isActive = isMunicipalityColumnFilterActive(viewState, filterParam) + const searchable = options.length >= SEARCHABLE_OPTION_THRESHOLD + + // Normalizing 435 labels per keystroke is the cost to avoid here, not the filter itself. + const searchIndex = useMemo( + () => options.map((option) => ({ option, haystack: normalizeSearchPhrase(option.label) })), + [options], + ) + const needle = normalizeSearchPhrase(query) + const visibleOptions = needle + ? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.option) + : options + + const commit = (next: MunicipalityListState) => { + setOptimistic({ baseKey: stateKey, next }) + if (definition.selection !== 'multi') setOpen(false) + } + + const multiParam = multiParamByFilter[filterParam] + const selectedMulti = multiParam ? getMunicipalityMultiFilterValues(viewState, multiParam) : [] + + /** + * Exclusive rows above the multi-select list: the Município column also owns + * "Prioritária", and the Assessores column owns the com/sem assessor toggle + * (it absorbed the former "Assessoria" column). + */ + const exclusiveRows = + filterParam === 'name' && showPriorityFilter + ? [ + { + label: municipalityPriorityLabels.alta, + selected: viewState.priority === 'alta', + next: toggleMunicipalityPriorityFilter(viewState), + }, + ] + : filterParam === 'advisor' + ? (getMunicipalityFilterDefinition('coverage').options ?? []).map((option) => ({ + label: option.label, + selected: viewState.coverage === option.value, + next: toggleMunicipalityExclusiveFilterValue(viewState, 'coverage', option.value), + })) + : [] + + const clearedState = + filterParam === 'name' + ? clearMunicipalityNameFilters(viewState) + : filterParam === 'advisor' + ? clearMunicipalityAdvisorFilters(viewState) + : multiParam + ? clearMunicipalityMultiFilter(viewState, multiParam) + : viewState + + const hasSelection = selectedMulti.length > 0 || exclusiveRows.some((row) => row.selected) + const showClear = searchable && hasSelection + + return ( + { + setOpen(next) + if (!next) setQuery('') + }} + > + + + + +
+

+ {definition.label} +

+ {showClear ? ( + commit(clearedState)} + > + Limpar + + ) : null} +
+ + {exclusiveRows.length ? ( +
+ {exclusiveRows.map((row) => ( + commit(row.next)} + > + {row.label} + + ))} +
+ ) : null} + + {searchable ? ( +
+ setQuery(event.target.value)} + placeholder="Buscar…" + aria-label={`Buscar em ${definition.label}`} + className="min-h-11" + autoFocus + /> +
+ ) : null} + + +
+
+ ) +} diff --git a/src/components/campaign/MunicipalityList.tsx b/src/components/campaign/MunicipalityList.tsx index 65779439..fa8c975e 100644 --- a/src/components/campaign/MunicipalityList.tsx +++ b/src/components/campaign/MunicipalityList.tsx @@ -1,6 +1,7 @@ -import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react' +import { CircleAlertIcon, SearchXIcon } from 'lucide-react' import Link from 'next/link' +import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending' import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack' import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl' import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl' @@ -8,24 +9,29 @@ import { MunicipalityListGoalCoverageCell } from '@/components/campaign/Municipa import { MunicipalityListTrendControl } from '@/components/campaign/MunicipalityListTrendControl' import { MunicipalitySortableHead } from '@/components/campaign/MunicipalitySortableHead' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' import { Table, TableBody, TableCaption, TableCell, - TableHead, TableHeader, TableRow, } from '@/components/ui/Table' import { formatElectionNumber } from '@/lib/electionInsights' -import { - formatMunicipalityVoteRank, - formatMunicipalityVoteShare, -} from '@/lib/municipalityVoteRank' +import { formatMunicipalityVoteRank, formatMunicipalityVoteShare } from '@/lib/municipalityVoteRank' import { cn } from '@/lib/utils' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { + buildMunicipalityListHref, formatMunicipalityConcentrationHint, formatMunicipalityGeographyLabel, formatMunicipalityListSortSummary, @@ -37,6 +43,7 @@ import { municipalityPriorityLabels, municipalitySignalAgeInDays, resolveMunicipalityListSort, + type MunicipalityFilterOption, type MunicipalityListState, } from '@/utilities/municipalityUi' import type { @@ -57,12 +64,19 @@ type MunicipalityStaffFormAction = ( formData: FormData, ) => Promise +type MunicipalityColumnFilterOptions = { + name: MunicipalityFilterOption[] + region: MunicipalityFilterOption[] + advisor: MunicipalityFilterOption[] +} + export type MunicipalityListProps = { municipalities: MunicipalityListViewModel[] advisorNamesById: ReadonlyMap isStaffView: boolean isCoordinator: boolean advisorOptions: EligibleAdvisorOption[] + columnFilterOptions: MunicipalityColumnFilterOptions trendFormAction: MunicipalityStaffFormAction advisorsFormAction: MunicipalityStaffFormAction state: MunicipalityListState @@ -147,26 +161,41 @@ const SignalAgeReadout = ({ * E9: a priority município with nobody answering for it is the queue's * loudest row, so it reads "Sem responsável" in destructive — the same words * the overview's coluna da vergonha uses to count them. Non-priority ones - * keep the softer pending tone: they are a gap, not a fire. + * keep the softer pending tone: they are a gap, not a fire. Rendered in place + * of the advisor names, so it only ever states an absence. */ -const AdvisorStatusBadge = ({ municipality }: { municipality: MunicipalityListViewModel }) => { - if (municipality.advisorIDs.length > 0) { - return ( - - - ) - } +const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) => ( + + +) - const isPriority = municipality.priority === 'alta' - return ( - - - ) -} +/** Replaces only the rows: the filter header row and the overview stay put. */ +const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => ( + + + + + Nenhum município encontrado + + Ajuste a busca ou os filtros. Você só vê municípios dentro do seu escopo. + + + + {/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */} + + Limpar busca e filtros + + + +) const advisorEntries = ( municipality: MunicipalityListViewModel, @@ -188,6 +217,7 @@ export const MunicipalityList = ({ isStaffView, isCoordinator, advisorOptions, + columnFilterOptions, trendFormAction, advisorsFormAction, state, @@ -199,6 +229,8 @@ export const MunicipalityList = ({ // The scenario picker is client state, so the server can only order by one // scenario — named here so the ordering never looks arbitrary. const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.` + // Keep in sync with the header row below. + const columnCount = isStaffView ? 9 : 5 return ( <> @@ -207,6 +239,7 @@ export const MunicipalityList = ({

+ {municipalities.length === 0 ? : null} {municipalities.map((municipality) => { const names = advisorNames(municipality, advisorNamesById) const position = municipality.votePosition2022 @@ -263,7 +296,7 @@ export const MunicipalityList = ({
-
Assessoria
+
Assessores
{isCoordinator ? ( + )}
@@ -291,20 +324,34 @@ export const MunicipalityList = ({ })}
-
- + {/* No inner scroller: the app shell's
scrolls, so the sticky header + resolves against it. A sticky
can't paint the row border, hence the + inset shadow standing in for it. */} +
+ {sortSummary}. Coluna 2022: {concentrationHint} - + - + Município - - Território de identidade + + Território - + Tipo {isStaffView ? ( <> - Assessores - + + Assessores + + Tendência @@ -329,9 +383,6 @@ export const MunicipalityList = ({ > Último sinal - - Assessoria - + {municipalities.length === 0 ? ( + + + + + + ) : null} {municipalities.map((municipality) => { return ( @@ -390,10 +448,12 @@ export const MunicipalityList = ({ options={advisorOptions} formAction={advisorsFormAction} /> - ) : ( + ) : municipality.advisorIDs.length ? ( + ) : ( + )} @@ -420,9 +480,6 @@ export const MunicipalityList = ({ layout="table" /> - - - { const href = buildMunicipalitySortHref(state, sortKey) const { sort: activeSort, dir } = resolveMunicipalityListSort(state) @@ -51,12 +60,11 @@ export const MunicipalitySortableHead = ({ replace scroll={false} className={cn( - 'group inline-flex min-h-11 items-center gap-1 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', - active - ? 'font-medium text-foreground hover:text-primary' - : 'font-normal text-muted-foreground hover:text-foreground', - align === 'right' && 'w-full justify-end', - align === 'center' && 'w-full justify-center', + 'group inline-flex min-h-11 items-center gap-1 rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + 'hover:text-foreground', + active ? 'font-medium' : 'font-normal', + align === 'right' && !filterParam && 'w-full justify-end', + align === 'center' && !filterParam && 'w-full justify-center', )} aria-label={ active @@ -80,25 +88,46 @@ export const MunicipalitySortableHead = ({ ) + const labeledSort = tooltip ? ( + + {sortControl} + + ) : ( + sortControl + ) + return ( - {tooltip ? ( - - {sortControl} - + {labeledSort} + + ) : ( - sortControl + labeledSort )} ) diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx index c2467b18..a7cb69d5 100644 --- a/src/components/ui/Table.tsx +++ b/src/components/ui/Table.tsx @@ -2,8 +2,19 @@ import * as React from 'react' import { cn } from '@/lib/utils' -const Table = ({ className, ...props }: React.ComponentProps<'table'>) => ( -
+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', () => { From 8430bd0a9f6efc04cea1289afbb7ecf2ef0188ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 03:44:25 -0300 Subject: [PATCH 2/2] =?UTF-8?q?B16:=20corrige=20filtros=20que=20se=20anula?= =?UTF-8?q?vam=20e=20registra=20os=20d=C3=A9bitos=20p=C3=B3s-simplify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A revisão paralela do /simplify achou três defeitos funcionais na entrega dos filtros no header, todos corrigidos aqui: - as facetas omitiam só o param multi, então "Prioritária" escondia os municípios não-prioritários do popover onde o próprio check mora e "Sem assessor" esvaziava o popover de Assessores; cada faceta passa a omitir o grupo de params do seu popover (int test cobre os dois casos); - "todas as tendências" tinha duas codificações — as três marcadas deixavam trend= inútil na URL com o funil se declarando inativo; o parse agora canonicaliza o conjunto completo para ausente; - o filtro de Território havia sumido do mobile ao virar multi. Também devolve o "Limpar" aos popovers curtos (estava preso ao limiar da busca) e registra o que sobrou como B16+ (escala/DRY), com o CampaignListEmptyState compartilhado e o firstValue duplicado absorvidos no C8, e a fragilidade do build sem o global metadata no site público. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/escala-dry-pos-b16.md | 110 ++++++++++++++++++ docs/plans/escala-dry-pos-c6.md | 2 + docs/roadmap.md | 22 ++-- .../campanha/(app)/municipios/page.tsx | 1 + .../MunicipalityAdvisorAvatarStack.tsx | 22 +++- .../campaign/MunicipalityFilters.tsx | 21 ++++ .../campaign/MunicipalityHeaderFilter.tsx | 19 ++- src/components/campaign/MunicipalityList.tsx | 39 +++---- .../MunicipalityListAdvisorsControl.tsx | 9 +- src/utilities/municipalityPageData.ts | 15 +-- src/utilities/municipalityUi.ts | 28 ++--- tests/int/municipalityPageData.int.spec.ts | 32 +++++ tests/unit/municipalityUi.unit.spec.ts | 10 ++ 14 files changed, 256 insertions(+), 75 deletions(-) create mode 100644 docs/plans/escala-dry-pos-b16.md diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index cde70381..c2655834 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **Correções e débitos pós-B16 (2026-07-25):** a revisão paralela achou três defeitos funcionais, **corrigidos na própria entrega**: as facetas omitiam só o param multi, então "Prioritária" escondia os não-prioritários do popover de Município e "Sem assessor" esvaziava o de Assessores (cada faceta passou a omitir o grupo do seu popover — `slugs`+`priority`, `advisors`+`coverage`, com int test); "todas as tendências" tinha duas codificações (as três marcadas deixavam `trend=` inútil na URL com o funil inativo — agora `parseMunicipalityListParams` canonicaliza o conjunto completo para ausente e `narrowingMunicipalityTrends` foi deletado); e o Território havia sumido do mobile ao virar multi (voltou como `MobileMultiFilterField` alimentado pela faceta, via novo prop `regionFilterOptions`). O `/simplify` da entrega (quality / performance / reuse em paralelo) aplicou o cleanup barato no próprio branch — setter único sobre params crus em `municipalityUi.ts` (fim de três casts e da escada de `if`), resumo de filtros de três funções para uma, remoção do array `municipalitySlugFilterOptions` (435 objetos que iam no bundle sem leitor), linha de opção única no Popover, facetas reusando o escopo já carregado e `getEligibleAdvisorOptions` de volta a coordenador-only. O que sobrou é maior que cleanup e virou o fill-in **B16+**: `useOptimistic` no lugar do par `{baseKey,next}` manual, payload dos rótulos de município (~20 KB por navegação) e hrefs do Popover a partir de uma base `URLSearchParams` em vez de duas serializações por opção — [`escala-dry-pos-b16.md`](../../../docs/plans/escala-dry-pos-b16.md). Adiados com gatilho no plano: gêmeo `MunicipalityFilterHead` (3º header com filtro), `ui/Command` no Popover (2º popover de busca), predicado de busca unificado, multi-seleção mobile honesta. Fora do `/campanha`: o `next build` inteiro morre quando o global `metadata` vem vazio (`/abaixo-assinado/[id]` chama `stripTrailingSlash` no `undefined`) — registrado em "Site público" no roadmap. - **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). diff --git a/docs/plans/escala-dry-pos-b16.md b/docs/plans/escala-dry-pos-b16.md new file mode 100644 index 00000000..9220c184 --- /dev/null +++ b/docs/plans/escala-dry-pos-b16.md @@ -0,0 +1,110 @@ +# Escala e DRY pós-B16 (filtros no header da lista) + +Status: rascunho +Atualizado em: 2026-07-25 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (B16+, fill-in de engenharia) +Impeccable: A — N/A (sem superfície UI nova; as fases preservam o comportamento visível de B16) +Appetite: ~0,5–1 dia eng; três fases independentes, nenhuma com migration +Responsável: — + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Não (N/A) — o lote é custo de render/transferência das opções de filtro já entregues em B16; nenhuma métrica nova. +- **Anti-goals de dado:** N/A. + +## Contexto + +**B16** ([filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md), entregue 2026-07-25) colocou um Popover de filtro em cada header da lista de municípios, com multi-seleção OR em município (435 opções), território, assessores e tendência. A passagem `/simplify` da entrega (três revisores em paralelo — quality / performance / reuse, 2026-07-25) aplicou o cleanup barato na hora e deixou três achados **maiores que cleanup**, registrados aqui. + +**Defeitos funcionais achados na revisão e corrigidos na entrega (não reabrir):** (1) as facetas omitiam só o param multi, então marcar "Prioritária" escondia todo município não-prioritário da lista onde o próprio check mora, e "Sem assessor" esvaziava o popover de Assessores — agora cada faceta omite o **grupo de params do seu popover** (`slugs`+`priority`, `advisors`+`coverage`), coberto por int test; (2) "todas as tendências" tinha duas codificações — selecionar as três deixava três `trend=` na URL filtrando nada, com o funil se declarando inativo — agora `parseMunicipalityListParams` canonicaliza o conjunto completo para ausente e `narrowingMunicipalityTrends` sumiu; (3) o Território tinha desaparecido do mobile (o filtro virou multi e o mobile só montava `single|toggle`), de volta como `MobileMultiFilterField` alimentado pela faceta. + +**Já resolvido no simplify (não reabrir):** setter único sobre params crus em `municipalityUi.ts` (elimina as duas escadas de `if` e os três casts `as BahiaIdentityTerritory[]` / `as PoliticalTrendStatus[]` / `as MunicipalityListState['coverage']`, além do `state.trends!`); cadeia de três funções do resumo de filtros colapsada com helper `firstNamesLabel`; remoção do array `municipalitySlugFilterOptions` (435 objetos montados no bundle do cliente e nunca lidos), do modo de seleção `'name'` e do campo `clearWhenOptionsAtLeast`; unificação das duas linhas de opção do Popover num `FilterOptionLink` com prop `checkbox`; `useEffect` de limpeza da busca dobrado no `onOpenChange`; dois `onClick={stopPropagation}` que não protegiam nada; memoização dos rótulos normalizados para busca; empty state da tabela navegando pela transição compartilhada e preservando `sort`/`dir`; `colSpan` derivado de uma constante junto do header; facets reusando o escopo já carregado na request (`loadMunicipalityScope` passou a selecionar `region`); `getEligibleAdvisorOptions` de volta a coordenador-only, com `loadAdvisorSummaries` rotulando o facet de assessores e as duas leituras em `Promise.all`; `allParamValues` sem o split por vírgula que nada emitia. + +## Objetivos + +- Um único mecanismo de estado otimista nos controles de filtro/sort, sem janela de reversão visível em cliques encadeados. +- Abrir o Popover de município (435 opções) sem custo de render proporcional a duas serializações de estado por opção. +- Parar de mandar ~20 KB de rótulos de município do servidor para o cliente a cada navegação da lista. +- Guardrails: sem migration, sem collection, sem `Consent`; contrato de URL (`?slug=`/`?region=`/`?advisor=`/`?trend=`) e comportamento visível de B16 inalterados. + +## Decisões travadas + +- **Um plano B16+, três fases independentes.** Mesmo precedente de `A9+`/`O0+`/`RS+`: um registro no roadmap, PR por fase, nenhuma fase bloqueia a outra. **Rejeitado:** um item de trilha B (não é feature, é custo herdado); três fill-ins separados (poluiria a lista para ~0,5d de trabalho). +- **Contrato de URL congelado.** As fases são internas ao render e ao payload; qualquer mudança em nome de param ou semântica de "todas as tendências = sem filtro" sai do escopo — **B18** (filtros salvos) já vai depender desse contrato. **Rejeitado:** aproveitar a Fase 2 para encurtar params (`?s=`), que quebraria URLs compartilhadas e o `resolveMunicipalityListUrl`. +- **i18n e naming** seguem o AGENTS.md: identificadores em inglês (`useOptimistic`, `buildMunicipalityFilterHref`, `municipalityFilterOptionsForSlugs`), strings visíveis em pt-BR. + +## Questões em aberto + +- **Fase 2: mandar slugs e rotular no cliente, ou só omitir quando o facet = catálogo inteiro?** **Opções:** (A) `columnFilterOptions.name` vira `string[]` e o Popover rotula com `municipalityNameBySlug` (já no bundle); (B) servidor manda `undefined` quando o facet cobre as 435 e o cliente cai no catálogo; (C) manter. **Recomendação:** A — corta o payload nos dois casos (filtrado e não filtrado) e mantém uma fonte de rótulo só; o custo é a assimetria de a coluna `name` receber valores enquanto `region`/`advisor` recebem pares. +- **Fase 3: `URLSearchParams` base + swap de um param, ou cap de lista?** **Opções:** (A) montar a base uma vez e trocar só o param da opção; (B) renderizar as primeiras N até a busca estreitar; (C) as duas. **Recomendação:** A — mantém a lista completa navegável por teclado/scroll (B muda UX sem pedido de campo) e é onde está o custo real (~870 parses por abertura). +- **Fase 1 vale o risco?** A janela de reversão só aparece em dois cliques antes de o servidor responder. **Recomendação:** fazer, mas com verificação manual do caso que originou o mecanismo atual (clicar em "Prioritária" e ver se não desmarca sozinha) antes de considerar pronta — foi exatamente o bug relatado em campo em 2026-07-25. + +## Abordagem proposta + +```mermaid +flowchart TD + B16["B16 ✓ Filtros no header"] --> F1 + F1["Fase 1 — useOptimistic
no Popover + onNavigate
dentro do startTransition"] + B16 --> F2["Fase 2 — payload dos rótulos
de município (slugs no lugar de pares)"] + B16 --> F3["Fase 3 — hrefs do Popover
a partir de uma base URLSearchParams"] + B16 --> F4["Fase 4 — faceta no mesmo Promise.all
+ DRY dos gêmeos de filtro"] + F1 -.se a config atrapalhar.-> F5["Fase 5 (condicional) — comportamento
na config de filtros ou params explícitos"] +``` + +Componentes: + +- **`src/components/campaign/MunicipalityHeaderFilter.tsx`** (Fase 1): trocar o par `{ baseKey, next }` em `useState` por `useOptimistic(state, (_, next: MunicipalityListState) => next)`. React descarta o valor otimista quando a transição de navegação assenta, o que apaga a comparação `optimistic?.baseKey === stateKey`. Precedente no código: `ActionPlanTaskChecklist.tsx`. +- **`src/components/campaign/CampaignListPending.tsx`** (Fase 1): mover `onNavigate?.()` para **dentro** do callback de `startTransition` — `useOptimistic` fora de transição dispara warning do React. O anchor é compartilhado (`MunicipalitySortableHead`, paginação, empty state), então a mudança precisa de smoke nas três superfícies. +- **`src/app/(campaign)/campanha/(app)/municipios/page.tsx` + `MunicipalityList.tsx`** (Fase 2): `columnFilterOptions.name` passa a carregar slugs; o Popover rotula com `municipalityFilterOptionsForSlugs` (já existe em `municipalityUi.ts`) no cliente. +- **`src/utilities/municipalityUi.ts`** (Fase 3): expor um builder que receba os params canônicos uma vez e devolva o href de cada opção trocando um único valor, em vez de `toggleMunicipalityMultiFilterValue` + `buildMunicipalityFilterHref` (duas voltas state→raw→parse) por opção renderizada. +- **`src/utilities/municipalityPageData.ts`** (Fase 4): hoje `loadMunicipalityListFilterFacets` é aguardado **depois** do `Promise.all` das leituras principais, então toda request com filtro ativo paga um round-trip serial a mais. A chave da semente sai de `where` de forma síncrona, então dá para passar a **promise** do escopo como semente e entrar no mesmo `Promise.all`. +- **DRY que caiu no mesmo lote** (Fase 4, tudo pequeno): `municipalitySlugSet`/`municipalityNameBySlug` refazem o que `isMunicipalitySlug`/`getMunicipalityCatalogEntry` já exportam em `municipalityCatalog.ts`; "limpar tudo preservando o sort" está escrito literalmente igual em `MunicipalityFilters.tsx` e `MunicipalityList.tsx` (vira `clearMunicipalityListFilters`); os selects mobile continuam lendo a verdade do servidor enquanto os popovers desktop já são otimistas — se a Fase 1 virar um hook, o mobile usa o mesmo. +- **Sem migration, sem collection, sem server action.** + +## Dependências + +- **B16 ✓** (entregue 2026-07-25) — este plano só existe sobre o que ele entregou. +- Suave com **B17** (seletor de colunas) e **B18** (filtros salvos): ambos pousam na mesma barra/header; fazer a Fase 1 antes evita replicar o mecanismo otimista atual num terceiro controle. +- Reusa `campaignListUrl.ts`, `municipalityUi.ts`, `campaignMunicipalityScope.ts`, `municipalityViewModels.ts`. + +## Não escopo + +- Qualquer mudança de UX dos filtros (novos params, chips, ordenação de opções) → **B17**/**B18** ou fill-in próprio. +- Unificar a busca do Popover com a busca da lista (`matchesAtWordStart` vs `includes`) → ver Adiado com gatilho. +- Resiliência do build do site público ao global `metadata` ausente → bullet próprio na seção "Site público" do roadmap (pai diferente; não entra neste lote). + +## Rabbit holes + +- **"Já que estou no Popover, uso `ui/Command`."** O componente existe (`src/components/ui/command.tsx`), mas trocar a lista por `Command` reescreve seleção, teclado e o modo checkbox de quatro filtros de uma vez. **Mitigação:** fora deste lote; ver gatilho abaixo. +- **"Aproveito para tornar a config de filtros realmente genérica."** `municipalityFilterDefinitions` hoje é genérica na forma e especializada em todo consumidor. Consertar de verdade significa mover comportamento (`toApplied(state, value)`) para a config e reescrever os dois consumidores. **Mitigação:** Fase 4 condicional — só se a Fase 1 esbarrar nela; senão, aceitar e documentar. +- **"Enquanto mexo no payload, mando o catálogo inteiro pro cliente e acabou."** Isso ressuscita os 435 objetos que o simplify acabou de remover do bundle. **Mitigação:** a Fase 2 manda **slugs do facet**, não o catálogo. + +## Adiado com gatilho + +- **`MunicipalityFilterHead` vs `MunicipalitySortableHead` (gêmeos).** Revisitar quando existir um **3º** header não-ordenável com filtro — hoje só "Assessores" justifica o par. +- **Popover sobre `ui/Command`.** Revisitar quando um **2º** Popover de busca com multi-seleção aparecer no produto (candidato: B17/B18). +- **Predicado de busca unificado (`includes` no Popover vs `matchesAtWordStart` na lista).** Revisitar se alguém em campo estranhar que buscar "cruz" no Popover casa "Santa Cruz" mas a busca da lista não se comporta igual. +- **Multi-seleção mobile honesta.** O `