Merge branch 'cursor/5274eb5f'
This commit is contained in:
commit
c2a3d73702
22 changed files with 1693 additions and 399 deletions
|
|
@ -7,6 +7,8 @@ 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 `<main>` (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 +19,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=<TI>`). 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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
110
docs/plans/escala-dry-pos-b16.md
Normal file
110
docs/plans/escala-dry-pos-b16.md
Normal file
|
|
@ -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<br/>no Popover + onNavigate<br/>dentro do startTransition"]
|
||||
B16 --> F2["Fase 2 — payload dos rótulos<br/>de município (slugs no lugar de pares)"]
|
||||
B16 --> F3["Fase 3 — hrefs do Popover<br/>a partir de uma base URLSearchParams"]
|
||||
B16 --> F4["Fase 4 — faceta no mesmo Promise.all<br/>+ DRY dos gêmeos de filtro"]
|
||||
F1 -.se a config atrapalhar.-> F5["Fase 5 (condicional) — comportamento<br/>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 `<select>` com `value=""` e prefixo `✓` é um stand-in deliberado (chips de `RelationMultiSelect`/`CampaignFilterChips` são a alternativa pronta). Revisitar se o R6 (critique da vertical) apontar o controle, ou se um assessor reclamar em uso real.
|
||||
- **Cap/virtualização das 435 âncoras do popover.** Só vale junto com a Fase 3 (ou com `ui/Command`); sozinho troca custo por UX pior. Revisitar se a Fase 3 não bastar em telefone de campo.
|
||||
- **Leitura única do catálogo acessível (≤435 linhas) com filtro/faceta/sort em memória.** Colapsaria list query + escopo + até 3 facetas numa leitura, mas mexe na semântica de E8/E9. Revisitar se as leituras full-scope aparecerem no p95 do `/campanha/municipios`.
|
||||
- **`getEligibleAdvisorOptions` devolvendo `phone`** para o coordenador derivar `loadAdvisorSummaries` em memória (−1 query). Revisitar quando a lista de assessores crescer além de algumas dezenas.
|
||||
- **`onNavigate` + estado otimista em `CampaignFilterChips` e na paginação.** Revisitar quando a Fase 1 extrair o hook — aí é adoção, não duplicação.
|
||||
|
||||
## Explicitamente fora (simplify desta sessão — não reabrir)
|
||||
|
||||
- Renomear/mover o gêmeo `MunicipalityFilterHead`, tirar `text-left`/`text-muted-foreground` redundantes do header, branches `align === 'right'|'center'` inalcançáveis no caminho com filtro (score ≤2).
|
||||
- `aria-live` do resumo de filtros re-anunciando por tecla: **pré-existente**, fora do diff de B16 — só entra se virar achado de a11y no R6.
|
||||
- Tolerância a params separados por vírgula em `allParamValues`: removida no simplify; não reintroduzir sem um caso real de URL editada à mão.
|
||||
- `columnCount = isStaffView ? 9 : 5` derivado de um array de descritores de coluna; over-select das facetas (chavear o cache por `where + select` quebraria a semente que torna o caso comum grátis); bundle de metas rodando no caminho de zero resultados (deliberado — a visão geral fica zerada na tela); quarta grafia de `{ value, label }` e o comparador `localeCompare('pt-BR')` repetido (drift pré-existente, não regressão deste diff).
|
||||
- `CampaignListEmptyState` compartilhado e as quatro cópias locais de `firstValue`: **absorvidos no C8** ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)) — são de outro pai (shells de lista de campanha), não de B16.
|
||||
|
||||
## Referências
|
||||
|
||||
- [filtros-no-header-lista-municipios.md](filtros-no-header-lista-municipios.md) — B16, entrega que originou o lote
|
||||
- [escala-dry-pos-reset-senha-perfil.md](escala-dry-pos-reset-senha-perfil.md) — precedente de plano por fases pós-simplify
|
||||
- `src/components/campaign/MunicipalityHeaderFilter.tsx`, `MunicipalitySortableHead.tsx`, `MunicipalityList.tsx`, `MunicipalityFilters.tsx`
|
||||
- `src/components/campaign/CampaignListPending.tsx`, `ActionPlanTaskChecklist.tsx` (precedente `useOptimistic`)
|
||||
- `src/utilities/municipalityUi.ts`, `src/utilities/municipalityPageData.ts`, `src/utilities/campaignListUrl.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/municipios/page.tsx`
|
||||
- `docs/roadmap.md` (fill-ins, B16+)
|
||||
- AGENTS.md — naming em inglês, `overrideAccess: false`, client boundary
|
||||
|
|
@ -75,6 +75,8 @@ flowchart TD
|
|||
|
||||
- **`src/utilities/drizzleBulk.ts`** (novo): extrair `chunk`, `requireTable`, `INSERT_CHUNK_SIZE`, e o scaffold de tipos `DrizzleTx`/`PayloadDb` de `supporterImportBulk.ts` e `electionResultsImport.ts` (hoje byte-idênticos). Importar de ambos.
|
||||
- ~~Variante de `getAccessibleNucleusIds` sem `PayloadRequest`~~ — **entregue** no split de access (hardening 2026-07-23): `getAdvisorMunicipalityIds` (`src/utilities/access/municipalities.ts`) é a implementação canônica do lookup `advisors contains user.id` sem `PayloadRequest`, reusada por `getAccessibleMunicipalityIds` (cache por contexto) e pelo aggregate.
|
||||
- **`CampaignListEmptyState`** (novo, `src/components/campaign/`): o bloco `Empty*` com `SearchXIcon` + "Ajuste a busca ou os filtros…" + CTA de limpar está copiado em sete superfícies — `apoiadores`, `liderancas`, `demandas`, `organizacoes`, `dobradinhas`, `planos` e `MunicipalityList` (que em B16, 2026-07-25, ganhou a melhor versão do CTA: `CampaignTransitionAnchor` preservando `sort`/`dir`, hoje ilhada numa das sete). Extrair `({ title, description, action })` e adotar pelo menos no par idêntico (apoiadores + municípios) antes de converter o resto. _(fonte: revisão de reuso pós-B16, 2026-07-25)_
|
||||
- **`firstValue` local em quatro parsers:** `campaignDemandData.ts`, `organizationData.ts`, `leadershipData.ts` e `municipalityUpdatePageData.ts` redeclaram byte a byte o `firstValue` já exportado por `campaignListUrl.ts` — trocar pelos imports (migrar essas listas para `resolveListUrl`/`buildListHref` continua fora deste plano). _(mesma fonte)_
|
||||
- **Type safety do bulk:** assertion runtime em `supporterImportBulk.ts` que `Object.keys(payload.db.tables.supporter)` contém as colunas esperadas (`contact`, `municipality`, `consent`, `createdBy`, …) no init do módulo — pega o bug `contactId` vs `contact` antes do runtime. (Widen de `PostgresTransactionDatabase` fica como follow-up de plataforma.)
|
||||
|
||||
### Fase 4 — DRY de forms — **ENTREGUE** (2026-07-21 + hardening 2026-07-23)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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+** débitos de escala/DRY pós-simplify do B16 registrados como fill-in; resiliência do build ao global `metadata` ausente no site público; **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`)
|
||||
|
||||
|
|
@ -58,15 +59,15 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
|
||||
O discovery literatura→persona→entrevista ([relatório aprovado](research/relatorio-entrevista-persona-campanha.md); compêndio com ~67 fontes em [docs/research/](research/)) fixou o kernel: a disputa de DF é conta de quociente fragmentada; o gargalo é converter lealdade de campo em voto nominal município a município via rede; % estadual absoluto é anti-métrica. A **sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](CUSTOMER.md))** confirmou as apostas centrais (fogo amigo intra-PT como ameaça nº 1; canal = ZAP sem registro datado; "coluna da vergonha" validada — Salvador cobrado 10×) e calibrou as âncoras: a leitura relativa da mesa é **% da própria votação** (concentração da captura própria — critério rígido de prioridade dele), não % do eleitorado local; a restrição dominante é **"perna"/estrutura**, não dinheiro; piso projetado **150 mil** (2022: 129k). O produto deve entregar **inteligência, não planilha chique**: metas derivadas, leitura relativa, fila de decisão priorizada, sugestões dado→decisão com humano no loop, registro ex-ante auditável. Plano-mestre: [inteligencia-campanha.md](plans/inteligencia-campanha.md) (incl. gaps de dados G1–G11 e desenho canônico da fila).
|
||||
|
||||
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
|
||||
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
|
||||
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
|
||||
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 |
|
||||
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
|
||||
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
|
||||
| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
|
||||
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- |
|
||||
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
|
||||
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
|
||||
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 |
|
||||
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
|
||||
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
|
||||
| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
|
||||
|
||||
### Demais itens abertos
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -96,6 +97,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
- **Ícone de prioridade na lista** em `/campanha/municipios` — trocar Badge “Prioritária” por ícone Flag + tooltip “Município prioritário” (hover) na coluna do nome · ~0,25d · Impeccable B · [plano](plans/icone-prioridade-lista-municipios.md)
|
||||
- **Cenário junto aos filtros** em `/campanha/municipios` — mover `VoteEstimateScenarioField` para a fileira/barra slim de `MunicipalityFilters` (com **B16**, a barra deixa de carregar os selects de coluna); overview só consome o contexto · ~0,25–0,5d · Impeccable B · soft: B16 · [plano](plans/cenario-junto-filtros-municipios.md)
|
||||
- **B16+** escala/DRY pós-B16 — `useOptimistic` no Popover do header (hoje há um par `{baseKey,next}` manual com janela de reversão em cliques encadeados), payload dos rótulos de município (~20 KB por navegação) e hrefs do Popover a partir de uma base `URLSearchParams` (435 opções × 2 serializações hoje) · ~0,5–1d · sem migration · soft: antes de **B17**/**B18** reusarem a barra · [plano](plans/escala-dry-pos-b16.md)
|
||||
- **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md)
|
||||
- **RS+** auth read leve + shells de senha · [plano](plans/escala-dry-pos-reset-senha-perfil.md)
|
||||
- **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md))
|
||||
|
|
@ -125,7 +127,7 @@ flowchart TD
|
|||
B8F2["B8 F2 Polígonos<br/>zonas de Salvador"]
|
||||
B14n["B14 Município mais próximo"]
|
||||
B15n["B15 ✓ Ordenar lista<br/>por coluna"]
|
||||
B16n["B16 Filtros no header<br/>da lista"]
|
||||
B16n["B16 ✓ Filtros no header<br/>da lista"]
|
||||
B17n["B17 Seletor de colunas<br/>da lista"]
|
||||
B18n["B18 Filtros salvos<br/>+ atalho sidebar"]
|
||||
B19n["B19 ✓ Gerenciar assessores"]
|
||||
|
|
@ -180,11 +182,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 +198,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
|
||||
|
||||
|
|
@ -215,6 +217,7 @@ Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9
|
|||
**Próximos:**
|
||||
|
||||
- Textos finais de privacidade + polish O0+ (revalidate globals, DRY Lexical) — mencionar cookies/Meta com Pixel em uso
|
||||
- **Build resiliente ao global `metadata` ausente** — `/abaixo-assinado/[id]` faz `stripTrailingSlash(globalMetadata.URL)` no `generateMetadata` e no componente; com o global vazio (banco novo, ou entrada de `unstable_cache` gravada vazia) o `next build` inteiro morre no prerender em vez de degradar. Dar fallback (`NEXT_PUBLIC_SITE_URL`) e omitir canonical/OG quando não houver URL · ~0,25d · _(fonte: simplify B16 2026-07-25 — build local quebrou com `.next/cache` envenenado; `rm -rf .next` contorna)_
|
||||
- `Pages` institucionais (bio, mandato, propostas) + hero/copy editáveis
|
||||
- Agenda/multimídia via links oficiais; CTA Doar → QueroApoiar
|
||||
- Migrar Consent dos fluxos públicos para chave estável
|
||||
|
|
|
|||
|
|
@ -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,49 @@ 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 = (
|
||||
<MunicipalityFilters
|
||||
key={buildMunicipalityFiltersKey(state)}
|
||||
state={state}
|
||||
showStaffFilters={isStaffView}
|
||||
regionFilterOptions={columnFilterOptions.region}
|
||||
advisorFilterOptions={columnFilterOptions.advisor}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -97,7 +112,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 ? (
|
||||
<MunicipalityListOverview view={overview} shameHref={shameHref} />
|
||||
|
|
@ -108,6 +125,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
advisorOptions={advisorOptions}
|
||||
columnFilterOptions={columnFilterOptions}
|
||||
trendFormAction={setMunicipalityPoliticalTrendFormAction}
|
||||
advisorsFormAction={assignMunicipalityAdvisorsFormAction}
|
||||
state={state}
|
||||
|
|
@ -123,23 +141,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum município encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros. Você só vê municípios dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/municipios">Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
// Shared transition: the filters navigate, the results dim ("Feel the action").
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { CircleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { municipalityListCoverageLabels } from '@/utilities/municipalityUi'
|
||||
|
||||
export const campaignUserInitials = (name: string): string =>
|
||||
name
|
||||
|
|
@ -13,14 +17,30 @@ export type MunicipalityAdvisorAvatarEntry = {
|
|||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. It stands in for the advisor
|
||||
* names, so it only ever states an absence.
|
||||
*/
|
||||
export const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) => (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
export const MunicipalityAdvisorAvatarStack = ({
|
||||
advisors,
|
||||
isPriority,
|
||||
maxVisible = 3,
|
||||
}: {
|
||||
advisors: MunicipalityAdvisorAvatarEntry[]
|
||||
isPriority: boolean
|
||||
maxVisible?: number
|
||||
}) => {
|
||||
if (!advisors.length) return <span className="text-muted-foreground">Sem assessor</span>
|
||||
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -8,30 +8,81 @@ 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
|
||||
}) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{options.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
|
||||
type MunicipalityFiltersProps = {
|
||||
state: MunicipalityListState
|
||||
showStaffFilters: boolean
|
||||
/** Territory options already narrowed by the other active filters. */
|
||||
regionFilterOptions: MunicipalityFilterOption[]
|
||||
/** Advisor options already narrowed by the other active filters. */
|
||||
advisorFilterOptions: MunicipalityFilterOption[]
|
||||
}
|
||||
|
||||
export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFiltersProps) => {
|
||||
export const MunicipalityFilters = ({
|
||||
state,
|
||||
showStaffFilters,
|
||||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
}: MunicipalityFiltersProps) => {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState(state.q ?? '')
|
||||
const sharedPending = useCampaignListPending()
|
||||
|
|
@ -41,6 +92,12 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 +113,28 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
}
|
||||
}
|
||||
|
||||
const commitNavigation = (patch: Partial<MunicipalityListState>) => {
|
||||
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<MunicipalityListState>) => {
|
||||
navigateTo({
|
||||
...state,
|
||||
...patch,
|
||||
q: normalizedText(patch.q !== undefined ? patch.q : search),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -82,15 +148,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 (
|
||||
<form
|
||||
|
|
@ -106,6 +168,7 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<CampaignSearchInput
|
||||
id="municipality-search"
|
||||
|
|
@ -118,136 +181,14 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
scheduleSearchNavigation(value)
|
||||
}}
|
||||
/>
|
||||
<Field className="md:w-56">
|
||||
<FieldLabel htmlFor="municipality-filter-region">Território de identidade</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-region"
|
||||
value={state.region ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
region: (event.target.value || undefined) as MunicipalityListState['region'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
{activeFiltersSummary ? (
|
||||
<p
|
||||
className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2 md:whitespace-normal"
|
||||
aria-live="polite"
|
||||
>
|
||||
<NativeSelectOption value="">Todos</NativeSelectOption>
|
||||
{bahiaIdentityTerritories.map((territory) => (
|
||||
<NativeSelectOption key={territory} value={territory}>
|
||||
{territory}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field className="md:w-44">
|
||||
<FieldLabel htmlFor="municipality-filter-kind">Tipo</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-kind"
|
||||
value={state.kind ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
kind: (event.target.value || undefined) as MunicipalityListState['kind'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todos</NativeSelectOption>
|
||||
{(
|
||||
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
|
||||
).map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{municipalityKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
{showStaffFilters ? (
|
||||
<>
|
||||
<Field className="md:w-44">
|
||||
<FieldLabel htmlFor="municipality-filter-coverage">Assessoria</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-coverage"
|
||||
value={state.coverage ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
coverage: (event.target.value ||
|
||||
undefined) as MunicipalityListState['coverage'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
{(
|
||||
Object.keys(municipalityListCoverageLabels) as Array<
|
||||
keyof typeof municipalityListCoverageLabels
|
||||
>
|
||||
).map((coverage) => (
|
||||
<NativeSelectOption key={coverage} value={coverage}>
|
||||
{municipalityListCoverageLabels[coverage]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field className="md:w-44">
|
||||
<FieldLabel htmlFor="municipality-filter-trend">Tendência</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-trend"
|
||||
value={state.trend ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
trend: (event.target.value || undefined) as MunicipalityListState['trend'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
{(
|
||||
Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>
|
||||
).map((trend) => (
|
||||
<NativeSelectOption key={trend} value={trend}>
|
||||
{politicalTrendLabels[trend]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field className="md:w-40">
|
||||
<FieldLabel htmlFor="municipality-filter-priority">Prioridade</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-priority"
|
||||
value={state.priority ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
priority: event.target.value === 'alta' ? 'alta' : undefined,
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
<NativeSelectOption value="alta">Prioritárias</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</>
|
||||
{activeFiltersSummary}
|
||||
</p>
|
||||
) : null}
|
||||
<Field className="md:hidden">
|
||||
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-sort"
|
||||
value={serializeMunicipalitySortValue(activeSort, activeDir)}
|
||||
onChange={(event) => {
|
||||
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 }) => (
|
||||
<NativeSelectOption
|
||||
key={serializeMunicipalitySortValue(key, dir)}
|
||||
value={serializeMunicipalitySortValue(key, dir)}
|
||||
>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
{hasActiveFilters ? (
|
||||
<div className="flex shrink-0 gap-2 md:self-end">
|
||||
<Button
|
||||
|
|
@ -270,6 +211,144 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{showStaffFilters ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-filter-priority">Prioridade</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-filter-priority"
|
||||
value={state.priority ?? ''}
|
||||
onChange={(event) => {
|
||||
navigateTo({
|
||||
...state,
|
||||
priority: event.target.value === 'alta' ? 'alta' : undefined,
|
||||
q: normalizedText(search),
|
||||
})
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
<NativeSelectOption value="alta">Prioritária</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
) : null}
|
||||
{regionFilterOptions.length ? (
|
||||
<MobileMultiFilterField
|
||||
id="municipality-filter-region"
|
||||
label="Território"
|
||||
emptyLabel="Todos"
|
||||
options={regionFilterOptions}
|
||||
selected={state.regions ?? []}
|
||||
onToggle={(value) =>
|
||||
navigateTo(
|
||||
toggleMunicipalityMultiFilterValue(
|
||||
{ ...state, q: normalizedText(search) },
|
||||
'region',
|
||||
value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{mobileFilterDefinitions.map((definition) => {
|
||||
const value =
|
||||
definition.param === 'kind' || definition.param === 'coverage'
|
||||
? (getMunicipalitySingleFilterValue(state, definition.param) ?? '')
|
||||
: ''
|
||||
return (
|
||||
<Field key={definition.param}>
|
||||
<FieldLabel htmlFor={`municipality-filter-${definition.param}`}>
|
||||
{definition.label}
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={`municipality-filter-${definition.param}`}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
>
|
||||
<NativeSelectOption value="">{definition.allLabel ?? 'Todas'}</NativeSelectOption>
|
||||
{(definition.options ?? []).map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
{showStaffFilters ? (
|
||||
<MobileMultiFilterField
|
||||
id="municipality-filter-trend"
|
||||
label="Tendência"
|
||||
emptyLabel="Todas"
|
||||
options={getMunicipalityFilterDefinition('trend').options ?? []}
|
||||
selected={state.trends ?? []}
|
||||
onToggle={(value) =>
|
||||
navigateTo(
|
||||
toggleMunicipalityMultiFilterValue(
|
||||
{ ...state, q: normalizedText(search) },
|
||||
'trend',
|
||||
value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showStaffFilters && advisorFilterOptions.length ? (
|
||||
<MobileMultiFilterField
|
||||
id="municipality-filter-advisor"
|
||||
label="Assessores"
|
||||
emptyLabel="Todos"
|
||||
options={advisorFilterOptions}
|
||||
selected={(state.advisors ?? []).map(String)}
|
||||
onToggle={(value) =>
|
||||
navigateTo(
|
||||
toggleMunicipalityMultiFilterValue(
|
||||
{ ...state, q: normalizedText(search) },
|
||||
'advisor',
|
||||
value,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="municipality-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-sort"
|
||||
value={serializeMunicipalitySortValue(activeSort, activeDir)}
|
||||
onChange={(event) => {
|
||||
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 }) => (
|
||||
<NativeSelectOption
|
||||
key={serializeMunicipalitySortValue(key, dir)}
|
||||
value={serializeMunicipalitySortValue(key, dir)}
|
||||
>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
311
src/components/campaign/MunicipalityHeaderFilter.tsx
Normal file
311
src/components/campaign/MunicipalityHeaderFilter.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
'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. */
|
||||
const SEARCHABLE_OPTION_THRESHOLD = 8
|
||||
|
||||
const multiParamByFilter = {
|
||||
name: 'slug',
|
||||
region: 'region',
|
||||
advisor: 'advisor',
|
||||
trend: 'trend',
|
||||
kind: null,
|
||||
coverage: null,
|
||||
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>
|
||||
|
||||
const FilterOptionLink = ({
|
||||
href,
|
||||
selected,
|
||||
checkbox = false,
|
||||
onChoose,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
selected: boolean
|
||||
checkbox?: boolean
|
||||
onChoose: () => void
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<CampaignTransitionAnchor
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center rounded-md px-2 text-sm whitespace-normal hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
checkbox ? 'gap-2' : selected && 'bg-muted font-medium',
|
||||
)}
|
||||
onNavigate={onChoose}
|
||||
>
|
||||
{checkbox ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className={cn(selected && 'font-medium')}>{children}</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
|
||||
export const MunicipalityHeaderFilter = ({
|
||||
state,
|
||||
filterParam,
|
||||
options: 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 = hasSelection
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-11 w-7 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={
|
||||
isActive
|
||||
? `Filtrar ${definition.label}: ativo. Alterar filtro`
|
||||
: `Filtrar por ${definition.label}`
|
||||
}
|
||||
>
|
||||
{isActive ? (
|
||||
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<p
|
||||
id={`municipality-filter-heading-${filterParam}`}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{definition.label}
|
||||
</p>
|
||||
{showClear ? (
|
||||
<CampaignTransitionAnchor
|
||||
href={buildMunicipalityFilterHref(clearedState)}
|
||||
replace
|
||||
scroll={false}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onNavigate={() => commit(clearedState)}
|
||||
>
|
||||
Limpar
|
||||
</CampaignTransitionAnchor>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{exclusiveRows.length ? (
|
||||
<div className="mb-1 flex flex-col gap-0.5 border-b border-border px-1 pb-1">
|
||||
{exclusiveRows.map((row) => (
|
||||
<FilterOptionLink
|
||||
key={row.label}
|
||||
href={buildMunicipalityFilterHref(row.next)}
|
||||
selected={row.selected}
|
||||
checkbox
|
||||
onChoose={() => commit(row.next)}
|
||||
>
|
||||
{row.label}
|
||||
</FilterOptionLink>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchable ? (
|
||||
<div className="px-1 pb-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar…"
|
||||
aria-label={`Buscar em ${definition.label}`}
|
||||
className="min-h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-labelledby={`municipality-filter-heading-${filterParam}`}
|
||||
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
{definition.selection === 'single' && definition.allLabel ? (
|
||||
<FilterOptionLink
|
||||
href={buildMunicipalityFilterHref(applyMunicipalityKindFilter(viewState, undefined))}
|
||||
selected={!getMunicipalitySingleFilterValue(viewState, 'kind')}
|
||||
onChoose={() => commit(applyMunicipalityKindFilter(viewState, undefined))}
|
||||
>
|
||||
{definition.allLabel}
|
||||
</FilterOptionLink>
|
||||
) : null}
|
||||
|
||||
{/* Tipo is the only single-select column; com/sem assessor rides along
|
||||
as an exclusive row of the Assessores column. */}
|
||||
{definition.selection === 'single'
|
||||
? visibleOptions.map((option) => {
|
||||
const next = applyMunicipalityKindFilter(viewState, option.value)
|
||||
return (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterHref(next)}
|
||||
selected={getMunicipalitySingleFilterValue(viewState, 'kind') === option.value}
|
||||
onChoose={() => commit(next)}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
|
||||
{multiParam
|
||||
? visibleOptions.map((option) => {
|
||||
const next = toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value)
|
||||
return (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterHref(next)}
|
||||
selected={selectedMulti.includes(option.value)}
|
||||
checkbox
|
||||
onChoose={() => commit(next)}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
|
||||
{visibleOptions.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{needle ? 'Nenhum resultado encontrado.' : 'Nenhuma opção no escopo filtrado.'}
|
||||
</p>
|
||||
) : null}
|
||||
</nav>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +1,40 @@
|
|||
import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import {
|
||||
MissingAdvisorBadge,
|
||||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/MunicipalityListGoalCoverageCell'
|
||||
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,
|
||||
|
|
@ -33,10 +42,10 @@ import {
|
|||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
municipalitySignalAgeInDays,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityFilterOption,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import type {
|
||||
|
|
@ -57,12 +66,19 @@ type MunicipalityStaffFormAction = (
|
|||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
|
||||
type MunicipalityColumnFilterOptions = {
|
||||
name: MunicipalityFilterOption[]
|
||||
region: MunicipalityFilterOption[]
|
||||
advisor: MunicipalityFilterOption[]
|
||||
}
|
||||
|
||||
export type MunicipalityListProps = {
|
||||
municipalities: MunicipalityListViewModel[]
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
trendFormAction: MunicipalityStaffFormAction
|
||||
advisorsFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
|
|
@ -102,8 +118,8 @@ const VotePositionReadout = ({
|
|||
* most municípios are past the 21-day threshold, and painting them all red
|
||||
* would drown the one state that really is an error in this list — a priority
|
||||
* município with nobody answering for it. Same reason it stays text with an
|
||||
* icon instead of a third badge: the row already carries the priority and
|
||||
* assessoria pills.
|
||||
* icon instead of a third badge: the row already carries the priority pill and,
|
||||
* when it applies, the missing-advisor one.
|
||||
*/
|
||||
const SignalAgeReadout = ({
|
||||
lastSignalAt,
|
||||
|
|
@ -143,30 +159,31 @@ 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.
|
||||
*/
|
||||
const AdvisorStatusBadge = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
|
||||
if (municipality.advisorIDs.length > 0) {
|
||||
return (
|
||||
<Badge variant="estimate-confirmed">
|
||||
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Coberta
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/** Replaces only the rows: the filter header row and the overview stay put. */
|
||||
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
|
||||
<Empty className="min-h-56">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum município encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros. Você só vê municípios dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
|
||||
<CampaignTransitionAnchor
|
||||
href={buildMunicipalityListHref({ page: 1, sort: state.sort, dir: state.dir }, 1)}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
>
|
||||
Limpar busca e filtros
|
||||
</CampaignTransitionAnchor>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
const advisorEntries = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
|
|
@ -188,6 +205,7 @@ export const MunicipalityList = ({
|
|||
isStaffView,
|
||||
isCoordinator,
|
||||
advisorOptions,
|
||||
columnFilterOptions,
|
||||
trendFormAction,
|
||||
advisorsFormAction,
|
||||
state,
|
||||
|
|
@ -199,6 +217,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,9 +227,11 @@ export const MunicipalityList = ({
|
|||
</p>
|
||||
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{municipalities.length === 0 ? <MunicipalityListEmptyState state={state} /> : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const names = advisorNames(municipality, advisorNamesById)
|
||||
const position = municipality.votePosition2022
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
|
|
@ -220,7 +242,7 @@ export const MunicipalityList = ({
|
|||
</p>
|
||||
{position ? <VotePositionReadout position={position} layout="card" /> : null}
|
||||
</div>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
{isPriority && isStaffView ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -263,13 +285,14 @@ export const MunicipalityList = ({
|
|||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Assessoria</dt>
|
||||
<dt className="text-muted-foreground">Assessores</dt>
|
||||
<dd>
|
||||
{isCoordinator ? (
|
||||
<MunicipalityListAdvisorsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
|
|
@ -277,7 +300,7 @@ export const MunicipalityList = ({
|
|||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
<MissingAdvisorBadge isPriority={isPriority} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -291,20 +314,34 @@ export const MunicipalityList = ({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div data-view="desktop-table" className="hidden overflow-hidden rounded-xl border md:block">
|
||||
<Table>
|
||||
{/* No inner scroller: the app shell's <main> scrolls, so the sticky header
|
||||
resolves against it. A sticky <th> can't paint the row border, hence the
|
||||
inset shadow standing in for it. */}
|
||||
<div data-view="desktop-table" className="hidden rounded-xl border md:block">
|
||||
<Table containerClassName="overflow-x-visible">
|
||||
<TableCaption className="sr-only">
|
||||
{sortSummary}. Coluna 2022: {concentrationHint}
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableHeader className="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0">
|
||||
<TableRow>
|
||||
<MunicipalitySortableHead state={state} sortKey="name">
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="name"
|
||||
filterParam="name"
|
||||
filterOptions={columnFilterOptions.name}
|
||||
showPriorityFilter={isStaffView}
|
||||
>
|
||||
Município
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="region">
|
||||
Território de identidade
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={columnFilterOptions.region}
|
||||
>
|
||||
Território
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="kind">
|
||||
<MunicipalitySortableHead state={state} sortKey="kind" filterParam="kind">
|
||||
Tipo
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
|
|
@ -315,8 +352,15 @@ export const MunicipalityList = ({
|
|||
/>
|
||||
{isStaffView ? (
|
||||
<>
|
||||
<TableHead>Assessores</TableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="trend">
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
filterParam="advisor"
|
||||
filterOptions={columnFilterOptions.advisor}
|
||||
>
|
||||
Assessores
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="trend" filterParam="trend">
|
||||
Tendência
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||||
|
|
@ -329,9 +373,6 @@ export const MunicipalityList = ({
|
|||
>
|
||||
Último sinal
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="coverage">
|
||||
Assessoria
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
|
|
@ -348,7 +389,15 @@ export const MunicipalityList = ({
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.length === 0 ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columnCount} className="whitespace-normal">
|
||||
<MunicipalityListEmptyState state={state} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<TableRow key={municipality.id}>
|
||||
<TableCell className="max-w-52 whitespace-normal">
|
||||
|
|
@ -359,7 +408,7 @@ export const MunicipalityList = ({
|
|||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
{isPriority && isStaffView ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -386,6 +435,7 @@ export const MunicipalityList = ({
|
|||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
|
|
@ -393,6 +443,7 @@ export const MunicipalityList = ({
|
|||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={isPriority}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
|
|
@ -420,9 +471,6 @@ export const MunicipalityList = ({
|
|||
layout="table"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
coverageByScenario={municipality.goalCoverageByScenario}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ type MunicipalityListAdvisorsControlProps = {
|
|||
municipalityID: number
|
||||
municipalitySlug: string
|
||||
currentAdvisorIDs: number[]
|
||||
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
|
||||
isPriority: boolean
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
options: EligibleAdvisorOption[]
|
||||
formAction: (
|
||||
|
|
@ -29,6 +31,7 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
municipalityID,
|
||||
municipalitySlug,
|
||||
currentAdvisorIDs,
|
||||
isPriority,
|
||||
advisorNamesById,
|
||||
options,
|
||||
formAction,
|
||||
|
|
@ -56,11 +59,7 @@ export const MunicipalityListAdvisorsControl = ({
|
|||
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Editar assessores"
|
||||
>
|
||||
{names.length ? (
|
||||
<MunicipalityAdvisorAvatarStack advisors={names} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">Sem assessor</span>
|
||||
)}
|
||||
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { MunicipalityHeaderFilter } from '@/components/campaign/MunicipalityHeaderFilter'
|
||||
import { MunicipalityHoverTooltip } from '@/components/campaign/MunicipalityHoverTooltip'
|
||||
import { TableHead } from '@/components/ui/Table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -12,6 +13,8 @@ import {
|
|||
defaultMunicipalityListSortDir,
|
||||
municipalityListSortLabels,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityFilterOption,
|
||||
type MunicipalityFilterParam,
|
||||
type MunicipalityListSortKey,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
|
|
@ -23,6 +26,9 @@ type MunicipalitySortableHeadProps = {
|
|||
tooltip?: ReactNode
|
||||
className?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
filterParam?: MunicipalityFilterParam
|
||||
filterOptions?: MunicipalityFilterOption[]
|
||||
showPriorityFilter?: boolean
|
||||
}
|
||||
|
||||
export const MunicipalitySortableHead = ({
|
||||
|
|
@ -32,6 +38,9 @@ export const MunicipalitySortableHead = ({
|
|||
tooltip,
|
||||
className,
|
||||
align = 'left',
|
||||
filterParam,
|
||||
filterOptions,
|
||||
showPriorityFilter,
|
||||
}: MunicipalitySortableHeadProps) => {
|
||||
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 = ({
|
|||
</CampaignTransitionAnchor>
|
||||
)
|
||||
|
||||
const labeledSort = tooltip ? (
|
||||
<MunicipalityHoverTooltip
|
||||
content={tooltip}
|
||||
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||
>
|
||||
{sortControl}
|
||||
</MunicipalityHoverTooltip>
|
||||
) : (
|
||||
sortControl
|
||||
)
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
aria-sort={ariaSort}
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'left' && 'text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tooltip ? (
|
||||
<MunicipalityHoverTooltip
|
||||
content={tooltip}
|
||||
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||
{filterParam ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'center' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{sortControl}
|
||||
</MunicipalityHoverTooltip>
|
||||
{labeledSort}
|
||||
<MunicipalityHeaderFilter
|
||||
state={state}
|
||||
filterParam={filterParam}
|
||||
options={filterOptions}
|
||||
showPriorityFilter={showPriorityFilter}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
sortControl
|
||||
labeledSort
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,19 @@ import * as React from 'react'
|
|||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = ({ className, ...props }: React.ComponentProps<'table'>) => (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
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
|
||||
}) => (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className={cn('relative w-full overflow-x-auto', containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@ export type RawSearchParams = Record<string, string | string[] | undefined>
|
|||
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<string>()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,98 @@ export type MunicipalityListOverviewData = {
|
|||
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
}
|
||||
|
||||
/** 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<Municipality, 'slug' | 'region' | 'advisors'>
|
||||
|
||||
/**
|
||||
* Each facet applies every filter owned by ANOTHER popover (so it only offers
|
||||
* values that still return rows) while omitting the ones its own popover owns —
|
||||
* including the checkbox/toggle sharing that popover, or selecting "Prioritária"
|
||||
* / "Sem assessor" would empty the very list it lives in. Identical `where`
|
||||
* shapes collapse into one read, and the already-loaded scope seeds the
|
||||
* unfiltered shape — with no filter active the facets cost no query at all.
|
||||
*/
|
||||
const loadMunicipalityListFilterFacets = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
state: MunicipalityListState,
|
||||
loadedScope: { where: Where; rows: MunicipalityFacetRow[] } | null,
|
||||
): Promise<MunicipalityListFilterFacets> => {
|
||||
const rowsByWhere = new Map<string, Promise<MunicipalityFacetRow[]>>()
|
||||
if (loadedScope) {
|
||||
rowsByWhere.set(JSON.stringify(loadedScope.where), Promise.resolve(loadedScope.rows))
|
||||
}
|
||||
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
|
||||
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, priority: undefined }),
|
||||
facetRows({ regions: undefined }),
|
||||
facetRows({ advisors: undefined, coverage: 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<string>([
|
||||
...regionRows.map((row) => row.region),
|
||||
...(state.regions ?? []),
|
||||
])
|
||||
const availableAdvisorIDs = new Set<number>(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<Record<MunicipalityListSortKey, string>> = {
|
||||
|
|
@ -176,6 +264,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
totalPages: 0,
|
||||
scopeTotal: 0,
|
||||
overview: null,
|
||||
filterFacets: emptyMunicipalityListFilterFacets,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +303,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<number, MunicipalityPledgeAggregate>()
|
||||
let goalCoverageByMunicipalityID = new Map<
|
||||
|
|
@ -221,7 +317,9 @@ export const loadMunicipalityListPageBundle = async (
|
|||
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
>()
|
||||
|
||||
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 +384,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
totalPages,
|
||||
scopeTotal: scopeCount.totalDocs,
|
||||
overview,
|
||||
filterFacets,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<NonNullable<Municipality['priori
|
|||
normal: 'Normal',
|
||||
}
|
||||
|
||||
export type PoliticalTrendStatus = NonNullable<NonNullable<Municipality['politicalTrend']>['status']>
|
||||
export type PoliticalTrendStatus = NonNullable<
|
||||
NonNullable<Municipality['politicalTrend']>['status']
|
||||
>
|
||||
|
||||
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
|
||||
favoravel: 'Favorável',
|
||||
|
|
@ -67,13 +70,13 @@ export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection =
|
|||
|
||||
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
|
||||
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,20 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
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. Never holds the full set: "todas" is
|
||||
* encoded as absent, canonicalized by `parseMunicipalityListParams`.
|
||||
*/
|
||||
trends?: PoliticalTrendStatus[]
|
||||
/** Candidate number for the map comparison mode (does not filter the list). */
|
||||
compare?: number
|
||||
sort?: MunicipalityListSortKey
|
||||
|
|
@ -99,6 +111,8 @@ export type MunicipalityListSearchParams = CampaignListRawSearchParams
|
|||
export const municipalityListParamNames = [
|
||||
'q',
|
||||
'region',
|
||||
'slug',
|
||||
'advisor',
|
||||
'kind',
|
||||
'coverage',
|
||||
'priority',
|
||||
|
|
@ -109,6 +123,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<string>(municipalityListParamNames)
|
||||
|
||||
// Derived from the label record (total over the union), so a new sort key is
|
||||
|
|
@ -215,17 +234,85 @@ 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<BahiaIdentityTerritory>()
|
||||
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<string>()
|
||||
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<number>()
|
||||
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<string>(Object.keys(politicalTrendLabels))
|
||||
|
||||
/** Selecting every trend means "todas", which is the same thing as selecting none. */
|
||||
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.length < politicalTrendStatusSet.size ? 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 +322,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 +348,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 +358,7 @@ 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 } })
|
||||
}
|
||||
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
|
@ -280,18 +367,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,14 +376,16 @@ 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).
|
||||
// Omit the default pair (staff: deficit+desc). Keep `sort` whenever the pair
|
||||
// is non-default so `dir` is never orphaned (e.g. votos+asc → sort=votos&dir=asc).
|
||||
if (!isListDefault) {
|
||||
params.set('sort', resolvedSort)
|
||||
if (resolvedDir !== defaultMunicipalityListSortDir(resolvedSort)) {
|
||||
|
|
@ -412,26 +490,250 @@ export const resolveMunicipalityListUrl = (
|
|||
totalPages,
|
||||
})
|
||||
|
||||
export const municipalityListCoverageLabels: Record<NonNullable<MunicipalityListState['coverage']>, string> = {
|
||||
export const municipalityListCoverageLabels: Record<
|
||||
NonNullable<MunicipalityListState['coverage']>,
|
||||
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<keyof typeof municipalityKindLabels>
|
||||
).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<keyof typeof politicalTrendLabels>).map(
|
||||
(trend) => ({ value: trend, label: politicalTrendLabels[trend] }),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const municipalityFilterDefinitionByParam = Object.fromEntries(
|
||||
municipalityFilterDefinitions.map((definition) => [definition.param, definition]),
|
||||
) as Record<MunicipalityFilterParam, MunicipalityFilterDefinition>
|
||||
|
||||
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 Boolean(state.trends?.length)
|
||||
}
|
||||
}
|
||||
|
||||
/** "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])
|
||||
if (state.trends?.length) {
|
||||
parts.push(
|
||||
`Tendência ${state.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 +741,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'}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,73 @@ 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('keeps the options of filters that share a popover with the applied one', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const priorityMunicipality = await fixtures.getMunicipality()
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: priorityMunicipality.id,
|
||||
data: { priority: 'alta' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const covered = await fixtures.getMunicipality(
|
||||
municipalityCatalog.find((entry) => entry.slug !== priorityMunicipality.slug)!.slug,
|
||||
)
|
||||
await fixtures.assignMunicipalityAdvisors(covered.id, [advisor.id])
|
||||
|
||||
// "Prioritária" lives in the Município popover, so it must not hide the
|
||||
// municipalities that popover is there to offer.
|
||||
const byPriority = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
priority: 'alta',
|
||||
})
|
||||
expect(byPriority.municipalities.every((row) => row.priority === 'alta')).toBe(true)
|
||||
expect(byPriority.filterFacets.slugs).toContain(covered.slug)
|
||||
|
||||
// Same for the "Sem assessor" toggle inside the Assessores popover: it would
|
||||
// otherwise leave that popover with nobody to pick.
|
||||
const withoutAdvisor = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
coverage: 'sem_assessor',
|
||||
})
|
||||
expect(withoutAdvisor.filterFacets.advisorIDs).toContain(advisor.id)
|
||||
})
|
||||
|
||||
it('returns an empty bundle for leaders (municipality list lockdown)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
|
|
|||
|
|
@ -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%')
|
||||
|
|
|
|||
|
|
@ -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,120 @@ 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'] } }],
|
||||
})
|
||||
|
||||
// "Todas" has a single encoding — absent — so the URL never carries params
|
||||
// that filter nothing while the funnel reports itself inactive.
|
||||
const all = parseMunicipalityListParams({ trend: ['favoravel', 'neutra', 'desfavoravel'] })
|
||||
expect(all.trends).toBeUndefined()
|
||||
expect(isMunicipalityColumnFilterActive(all, 'trend')).toBe(false)
|
||||
expect(buildMunicipalityListWhere(all)).toEqual({})
|
||||
expect(formatMunicipalityActiveFiltersSummary(all)).toBeNull()
|
||||
expect(buildMunicipalityFilterHref(all)).toBe('/campanha/municipios')
|
||||
|
||||
// Ticking the last unchecked box lands on that same canonical state.
|
||||
const twoSelected = parseMunicipalityListParams({ trend: ['favoravel', 'neutra'] })
|
||||
expect(
|
||||
toggleMunicipalityMultiFilterValue(twoSelected, 'trend', 'desfavoravel').trends,
|
||||
).toBeUndefined()
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue