B16: corrige filtros que se anulavam e registra os débitos pós-simplify
A revisão paralela do /simplify achou três defeitos funcionais na entrega dos filtros no header, todos corrigidos aqui: - as facetas omitiam só o param multi, então "Prioritária" escondia os municípios não-prioritários do popover onde o próprio check mora e "Sem assessor" esvaziava o popover de Assessores; cada faceta passa a omitir o grupo de params do seu popover (int test cobre os dois casos); - "todas as tendências" tinha duas codificações — as três marcadas deixavam trend= inútil na URL com o funil se declarando inativo; o parse agora canonicaliza o conjunto completo para ausente; - o filtro de Território havia sumido do mobile ao virar multi. Também devolve o "Limpar" aos popovers curtos (estava preso ao limiar da busca) e registra o que sobrou como B16+ (escala/DRY), com o CampaignListEmptyState compartilhado e o firstValue duplicado absorvidos no C8, e a fragilidade do build sem o global metadata no site público.
This commit is contained in:
parent
96bf362421
commit
8430bd0a9f
14 changed files with 256 additions and 75 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **Correções e débitos pós-B16 (2026-07-25):** a revisão paralela achou três defeitos funcionais, **corrigidos na própria entrega**: as facetas omitiam só o param multi, então "Prioritária" escondia os não-prioritários do popover de Município e "Sem assessor" esvaziava o de Assessores (cada faceta passou a omitir o grupo do seu popover — `slugs`+`priority`, `advisors`+`coverage`, com int test); "todas as tendências" tinha duas codificações (as três marcadas deixavam `trend=` inútil na URL com o funil inativo — agora `parseMunicipalityListParams` canonicaliza o conjunto completo para ausente e `narrowingMunicipalityTrends` foi deletado); e o Território havia sumido do mobile ao virar multi (voltou como `MobileMultiFilterField` alimentado pela faceta, via novo prop `regionFilterOptions`). O `/simplify` da entrega (quality / performance / reuse em paralelo) aplicou o cleanup barato no próprio branch — setter único sobre params crus em `municipalityUi.ts` (fim de três casts e da escada de `if`), resumo de filtros de três funções para uma, remoção do array `municipalitySlugFilterOptions` (435 objetos que iam no bundle sem leitor), linha de opção única no Popover, facetas reusando o escopo já carregado e `getEligibleAdvisorOptions` de volta a coordenador-only. O que sobrou é maior que cleanup e virou o fill-in **B16+**: `useOptimistic` no lugar do par `{baseKey,next}` manual, payload dos rótulos de município (~20 KB por navegação) e hrefs do Popover a partir de uma base `URLSearchParams` em vez de duas serializações por opção — [`escala-dry-pos-b16.md`](../../../docs/plans/escala-dry-pos-b16.md). Adiados com gatilho no plano: gêmeo `MunicipalityFilterHead` (3º header com filtro), `ui/Command` no Popover (2º popover de busca), predicado de busca unificado, multi-seleção mobile honesta. Fora do `/campanha`: o `next build` inteiro morre quando o global `metadata` vem vazio (`/abaixo-assinado/[id]` chama `stripTrailingSlash` no `undefined`) — registrado em "Site público" no roadmap.
|
||||
- **B16 entregue em código (2026-07-25):** filtros no header das colunas em `/campanha/municipios` — funil + Popover (`MunicipalityHeaderFilter`) ao lado do sort do B15, com multi-seleção OR em Território (`region`), Município (`slug`, com busca acento-insensível e o check "Prioritária" no mesmo popover), Assessores (`advisor`, que também hospeda o toggle com/sem assessor) e Tendência (`trend`); "Limpar" nos multi grandes; mobile mantém os selects empilhados. Header sticky resolvendo contra o scroller do `<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).
|
||||
|
|
|
|||
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,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-25 (**B16 ✓** filtros no header da lista; **E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); **B19 ✓** gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
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)
|
||||
|
|
@ -59,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
|
||||
|
||||
|
|
@ -97,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))
|
||||
|
|
@ -216,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
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
key={buildMunicipalityFiltersKey(state)}
|
||||
state={state}
|
||||
showStaffFilters={isStaffView}
|
||||
regionFilterOptions={columnFilterOptions.region}
|
||||
advisorFilterOptions={columnFilterOptions.advisor}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ const MobileMultiFilterField = ({
|
|||
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[]
|
||||
}
|
||||
|
|
@ -78,6 +80,7 @@ type MunicipalityFiltersProps = {
|
|||
export const MunicipalityFilters = ({
|
||||
state,
|
||||
showStaffFilters,
|
||||
regionFilterOptions,
|
||||
advisorFilterOptions,
|
||||
}: MunicipalityFiltersProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -230,6 +233,24 @@ export const MunicipalityFilters = ({
|
|||
</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'
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ type MunicipalityHeaderFilterProps = {
|
|||
showPriorityFilter?: boolean
|
||||
}
|
||||
|
||||
/** Above this many options the popover gets a search box and a Limpar action. */
|
||||
/** Above this many options the popover gets a search box. */
|
||||
const SEARCHABLE_OPTION_THRESHOLD = 8
|
||||
|
||||
const multiParamByFilter = {
|
||||
|
|
@ -170,7 +170,7 @@ export const MunicipalityHeaderFilter = ({
|
|||
: viewState
|
||||
|
||||
const hasSelection = selectedMulti.length > 0 || exclusiveRows.some((row) => row.selected)
|
||||
const showClear = searchable && hasSelection
|
||||
const showClear = hasSelection
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
|
@ -264,21 +264,16 @@ export const MunicipalityHeaderFilter = ({
|
|||
</FilterOptionLink>
|
||||
) : null}
|
||||
|
||||
{definition.selection === 'single' || definition.selection === 'toggle'
|
||||
{/* 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 current =
|
||||
filterParam === 'kind' || filterParam === 'coverage'
|
||||
? getMunicipalitySingleFilterValue(viewState, filterParam)
|
||||
: undefined
|
||||
const next =
|
||||
definition.selection === 'toggle'
|
||||
? toggleMunicipalityExclusiveFilterValue(viewState, 'coverage', option.value)
|
||||
: applyMunicipalityKindFilter(viewState, option.value)
|
||||
const next = applyMunicipalityKindFilter(viewState, option.value)
|
||||
return (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterHref(next)}
|
||||
selected={current === option.value}
|
||||
selected={getMunicipalitySingleFilterValue(viewState, 'kind') === option.value}
|
||||
onChoose={() => commit(next)}
|
||||
>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
|
|||
import Link from 'next/link'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/MunicipalityAdvisorAvatarStack'
|
||||
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'
|
||||
|
|
@ -39,7 +42,6 @@ import {
|
|||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
municipalitySignalAgeInDays,
|
||||
resolveMunicipalityListSort,
|
||||
|
|
@ -116,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,
|
||||
|
|
@ -157,20 +159,6 @@ 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. Rendered in place
|
||||
* of the advisor names, so it only ever states an absence.
|
||||
*/
|
||||
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>
|
||||
)
|
||||
|
||||
/** Replaces only the rows: the filter header row and the overview stay put. */
|
||||
const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState }) => (
|
||||
<Empty className="min-h-56">
|
||||
|
|
@ -243,6 +231,7 @@ export const MunicipalityList = ({
|
|||
{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">
|
||||
|
|
@ -253,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>
|
||||
|
|
@ -303,6 +292,7 @@ export const MunicipalityList = ({
|
|||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
|
|
@ -310,7 +300,7 @@ export const MunicipalityList = ({
|
|||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
<MissingAdvisorBadge isPriority={municipality.priority === 'alta'} />
|
||||
<MissingAdvisorBadge isPriority={isPriority} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -407,6 +397,7 @@ export const MunicipalityList = ({
|
|||
</TableRow>
|
||||
) : null}
|
||||
{municipalities.map((municipality) => {
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<TableRow key={municipality.id}>
|
||||
<TableCell className="max-w-52 whitespace-normal">
|
||||
|
|
@ -417,7 +408,7 @@ export const MunicipalityList = ({
|
|||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
{municipality.priority === 'alta' && isStaffView ? (
|
||||
{isPriority && isStaffView ? (
|
||||
<Badge variant="destructive">{municipalityPriorityLabels.alta}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -444,16 +435,16 @@ export const MunicipalityList = ({
|
|||
municipalityID={municipality.id}
|
||||
municipalitySlug={municipality.slug}
|
||||
currentAdvisorIDs={municipality.advisorIDs}
|
||||
isPriority={isPriority}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
formAction={advisorsFormAction}
|
||||
/>
|
||||
) : municipality.advisorIDs.length ? (
|
||||
) : (
|
||||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisorEntries(municipality, advisorNamesById)}
|
||||
isPriority={isPriority}
|
||||
/>
|
||||
) : (
|
||||
<MissingAdvisorBadge isPriority={municipality.priority === 'alta'} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -90,11 +90,12 @@ const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
|
|||
type MunicipalityFacetRow = Pick<Municipality, 'slug' | 'region' | 'advisors'>
|
||||
|
||||
/**
|
||||
* Each facet applies every OTHER active filter (so the popover only offers values
|
||||
* that still return rows) while omitting its own param (so the user can keep
|
||||
* adding to the OR set). Identical `where` shapes collapse into one read, and the
|
||||
* already-loaded scope seeds the unfiltered shape — with no multi-select active
|
||||
* (the common case) the facets cost no query at all.
|
||||
* 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,
|
||||
|
|
@ -129,9 +130,9 @@ const loadMunicipalityListFilterFacets = async (
|
|||
}
|
||||
|
||||
const [slugRows, regionRows, advisorRows] = await Promise.all([
|
||||
facetRows({ slugs: undefined }),
|
||||
facetRows({ slugs: undefined, priority: undefined }),
|
||||
facetRows({ regions: undefined }),
|
||||
facetRows({ advisors: undefined }),
|
||||
facetRows({ advisors: undefined, coverage: undefined }),
|
||||
])
|
||||
|
||||
// Selected values are unioned in: a selection must stay visible to be undone.
|
||||
|
|
|
|||
|
|
@ -95,7 +95,10 @@ export type MunicipalityListState = {
|
|||
kind?: Municipality['kind']
|
||||
coverage?: 'com_assessor' | 'sem_assessor'
|
||||
priority?: 'alta'
|
||||
/** Multi-select (OR) political trends. All or none selected means "todas". */
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -268,6 +271,7 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => {
|
|||
|
||||
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)) {
|
||||
|
|
@ -276,7 +280,7 @@ const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendSta
|
|||
if (trends.includes(trend)) continue
|
||||
trends.push(trend)
|
||||
}
|
||||
return trends
|
||||
return trends.length < politicalTrendStatusSet.size ? trends : []
|
||||
}
|
||||
|
||||
const municipalityListStateToRawParams = (
|
||||
|
|
@ -354,8 +358,7 @@ export const buildMunicipalityListWhere = (state: MunicipalityListState): Where
|
|||
})
|
||||
}
|
||||
if (state.priority) filters.push({ priority: { equals: state.priority } })
|
||||
const trends = narrowingMunicipalityTrends(state)
|
||||
if (trends.length) filters.push({ 'politicalTrend.status': { in: trends } })
|
||||
if (state.trends?.length) filters.push({ 'politicalTrend.status': { in: state.trends } })
|
||||
|
||||
return filters.length ? { and: filters } : {}
|
||||
}
|
||||
|
|
@ -381,8 +384,8 @@ export const buildMunicipalityListSearchParams = (
|
|||
if (canonicalState.priority) params.set('priority', canonicalState.priority)
|
||||
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)) {
|
||||
|
|
@ -692,16 +695,10 @@ export const isMunicipalityColumnFilterActive = (
|
|||
case 'coverage':
|
||||
return Boolean(state.coverage)
|
||||
case 'trend':
|
||||
return narrowingMunicipalityTrends(state).length > 0
|
||||
return Boolean(state.trends?.length)
|
||||
}
|
||||
}
|
||||
|
||||
/** All (or no) trends selected means "todas", so neither narrows the list. */
|
||||
const narrowingMunicipalityTrends = (state: MunicipalityListState): PoliticalTrendStatus[] => {
|
||||
const trends = state.trends ?? []
|
||||
return trends.length < politicalTrendStatusSet.size ? trends : []
|
||||
}
|
||||
|
||||
/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */
|
||||
const firstNamesLabel = (names: string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
|
@ -720,10 +717,9 @@ export const formatMunicipalityActiveFiltersSummary = (
|
|||
parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`)
|
||||
}
|
||||
if (state.coverage) parts.push(municipalityListCoverageLabels[state.coverage])
|
||||
const trends = narrowingMunicipalityTrends(state)
|
||||
if (trends.length) {
|
||||
if (state.trends?.length) {
|
||||
parts.push(
|
||||
`Tendência ${trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
|
||||
`Tendência ${state.trends.map((trend) => politicalTrendLabels[trend].toLowerCase()).join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
|
|
|
|||
|
|
@ -330,6 +330,38 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -173,10 +173,20 @@ describe('municipality list header filters (B16+)', () => {
|
|||
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue