B21: cria página própria dos territórios
Leva a leitura dos 27 Territórios de Identidade para o sistema de listas com estado canônico na URL e remove o painel duplicado do Início.
This commit is contained in:
parent
ac671f4bc0
commit
a9e1900290
30 changed files with 1698 additions and 579 deletions
|
|
@ -10,6 +10,7 @@ alwaysApply: false
|
|||
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
|
||||
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
|
||||
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
|
||||
- **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=<TI>`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md).
|
||||
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
|
||||
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
target: src/app/(campaign)/campanha/(app)/territorios/page.tsx
|
||||
total_score: 25
|
||||
p0_count: 0
|
||||
p1_count: 2
|
||||
timestamp: 2026-07-25T23-12-35Z
|
||||
slug: src-app-campaign-campanha-app-territorios-page-tsx
|
||||
---
|
||||
|
||||
Method: dual-agent (A: 216f0e46-ffcc-4caf-8c2e-947cb6fe8d8c · B: 7e537205-ca99-4e97-ac92-1742875a045b)
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key issue |
|
||||
| --------- | ------------------------------- | --------: | --------------------------------------------------- |
|
||||
| 1 | Visibility of system status | 3 | Pending region is honest and announced |
|
||||
| 2 | Match system / real world | 2 | Metric labels need explicit semantics |
|
||||
| 3 | User control and freedom | 3 | Clear search/filter recovery |
|
||||
| 4 | Consistency and standards | 3 | Reuses the campaign list system |
|
||||
| 5 | Error prevention | 2 | Mobile comparison can lose row context |
|
||||
| 6 | Recognition rather than recall | 2 | Mobile selected territory was hidden behind a count |
|
||||
| 7 | Flexibility and efficiency | 3 | URL-backed sort/filter supports power use |
|
||||
| 8 | Aesthetic and minimalist design | 3 | Sober field-desk visual language |
|
||||
| 9 | Error recovery | 3 | Empty state offers direct recovery |
|
||||
| 10 | Help and documentation | 1 | Intelligence metrics lack contextual definitions |
|
||||
| **Total** | | **25/40** | **Acceptable** |
|
||||
|
||||
## Anti-patterns verdict
|
||||
|
||||
No classic AI slop. The restrained, flat field-desk styling is consistent with Teqo. The deterministic scan returned zero findings. Browser evidence found one functional gap that the detector could not see: on mobile, the empty state was centered across the full table width and started outside the visible horizontal viewport.
|
||||
|
||||
## What's working
|
||||
|
||||
- URL-backed filters and sorting make the view shareable and recoverable.
|
||||
- Pending feedback dims the result region and announces updates without blocking the controls.
|
||||
- The table reuses the campaign list system and keeps the data density appropriate for staff.
|
||||
|
||||
## Priority issues
|
||||
|
||||
- **P1 — Mobile row context and empty recovery:** the horizontal table is an approved product decision, but an 888–974 px table in a 356 px viewport needs a sticky territory column, and the empty recovery must remain in the initial viewport.
|
||||
- **P1 — Ambiguous metric labels:** “% da votação”, “Estimativa 2026” and “Com assessor” hide denominator, scenario and coverage semantics.
|
||||
- **P2 — Mobile active-filter recall:** a selected territory was reduced to “1 selecionado(s)” while the active summary was desktop-only.
|
||||
- **P2 — Historical disclosure:** the 2022 value lacked a disclosure signifier and its tap target was below 44 px.
|
||||
- **P2 — Action promise mismatch:** the intro promised allocation decisions while the current metrics support regional comparison.
|
||||
|
||||
## Persona red flags
|
||||
|
||||
- **Alex (power user):** can sort and share URLs quickly, but this slice deliberately does not yet include E12 regional deficit/LQ metrics.
|
||||
- **Sam (keyboard/screen reader):** horizontal reflow is demanding; the historical disclosure and parent/sub-row relationship need explicit cues.
|
||||
- **Casey (mobile field user):** selected filter names and row context must survive horizontal movement.
|
||||
|
||||
## Minor observations
|
||||
|
||||
- The generic campaign loading shell is broader than this list.
|
||||
- The footer confirms quantity but intentionally avoids inventing a recommendation.
|
||||
- The detector returned no automated rule findings; the material issues were interaction and semantics.
|
||||
|
||||
## Questions
|
||||
|
||||
- Which three regional metrics should remain visible without horizontal movement once E12 lands?
|
||||
- Should Territórios become a mobile bottom-nav destination only after it carries actionable regional intelligence?
|
||||
|
|
@ -66,7 +66,9 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-24):** **E4R** Import único da planilha de projeção — `pnpm db:seed:projecao` (`scripts/import-projecao.mjs`) lê o xlsx canônico em `docs/sheets/`, faz match via `canonicalizeMunicipalityName` + `municipalityCatalog`, e **sempre sobrescreve** `municipality.expectedVotes` + `priority` (re-seed quando a mesa manda planilha nova); Salvador pulado; `--dry-run` + guard `ALLOW_REMOTE_DB`; parser puro em `src/lib/projectionSheetParse.ts` (Bom→`optimistic`, Regular→`central`, Mínimo→`pessimistic`). **Fase 2 (mesmo dia, decisão do produto — não é mais zero PII):** o seed também importa as colunas de estratégia, com PRIORITÁRIAS sobrepondo MAPA GERAL quando não-vazia — SITUAÇÃO→`politicalTrend` (QUEDA/MANTÉM/AUMENTO→desfavorável/neutra/favorável, com nota de proveniência), DOBRADINHAS→`stateDeputy`+`stateDeputies` (união; célula crua em `dobradinhaNotes`), LIDERANÇAS→`contact` name-only **sem telefone** + `leadership` (dedup estadual por slug do nome; `Contact.phone` virou opcional via migration `20260724_175500_contact_phone_optional`, UI continua exigindo), ASSESSOR RESPONSÁVEL→`campaignUser`+`advisors` (união; Edizio=coordinator, Solla=candidate — `candidate` agora é elegível como assessor em `eligibleCampaignStaffWhere`; novos usuários com email `<slug>@planilha.invalid` + senha aleatória, sem login até um admin trocar), ENCAMINHAMENTOS→`nextSteps`, OBSERVAÇÃO→`strengths`/`risks` (classificação curada por slug). Relações são união (nunca removem vínculos da UI); re-run idempotente; nomes de atores políticos entraram sem trilha de consentimento — regularizar com a assessoria jurídica. No mesmo dia, o grupo duplicado `municipality.voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido** — collection, zod, view models, form/card e utilitário `voteGoals.ts` — via migration `20260724_133600_drop_municipality_vote_goals` (backfill metas→estimativas onde a estimativa estava vazia): `expectedVotes` (pessimista/média/otimista) é a única série por cenário do município. Plano: [`docs/plans/import-planilha-projecao.md`](docs/plans/import-planilha-projecao.md).
|
||||
|
||||
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, planos-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActionPlanOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
|
||||
**Recently resolved (2026-07-25):** **B21 Página dos Territórios de Identidade** — nova rota staff-only `/campanha/territorios` com os 27 TIs no sistema de listas: sort/filtros/busca canônicos na URL, Metropolitano decomposto em sub-linhas, primeira coluna sticky no mobile e linha → `/campanha/municipios?region=<TI>`. O chrome rico agora é compartilhado por `CampaignSortableHead`/`CampaignHeaderFilterPopover`; wrappers de municípios mantêm a política de domínio. O painel de TI e `territorySlot` foram removidos do Início. Sem migration/collection/action. Plano: [`docs/plans/pagina-territorios-identidade.md`](docs/plans/pagina-territorios-identidade.md).
|
||||
|
||||
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
|
||||
|
||||
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
Status: rascunho
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B33)
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — ver Dependências)
|
||||
Appetite: ~0,5–0,75 dia eng se `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existirem (B29 ou B21 já entregues); ~1–1,5 dia eng se este item chegar primeiro e precisar fazer a extração
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado entregue pelo **B21 ✓**
|
||||
Appetite: ~0,5–0,75 dia eng; `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existem
|
||||
Responsável: —
|
||||
|
||||
**Atualização B21 (2026-07-25):** a corrida de ordem de chegada foi encerrada. B33 não extrai nem duplica chrome; implementa somente `StateDeputySortableHead`/`StateDeputyHeaderFilter` e seus módulos de URL/filtros.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`) e o par compartilhado que **B29** promove para `shared/`.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ Status: rascunho
|
|||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B29)
|
||||
Impeccable: B — encaixe em tela existente (`/campanha/liderancas`), sem rota nova; herda o header rico de `/campanha/municipios` (B15 ✓ + B16 ✓)
|
||||
Appetite: ~1,5–2 dias eng; sem migration — módulo de URL + filtros no loader + duas colunas + extração do par head/filtro para `shared/` (com municípios migrado junto)
|
||||
Appetite: ~1–1,5 dia eng; sem migration — módulo de URL + filtros no loader + duas colunas; o chrome compartilhado já foi entregue pelo B21
|
||||
Responsável: —
|
||||
|
||||
**Atualização B21 (2026-07-25):** a extração deixou de fazer parte do appetite deste item. `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` já existem, e os wrappers de municípios já foram migrados. B29 implementa apenas os wrappers/política de liderança e o contrato de URL/filtros próprios.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
# B21 — Página própria dos Territórios de Identidade (overview + tabela)
|
||||
|
||||
Status: rascunho
|
||||
Status: ✓ entregue em 2026-07-25
|
||||
Atualizado em: 2026-07-25
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B21; conteúdo herdado de E17 ✓ e estendido por E12)
|
||||
Impeccable: C — rota nova `/campanha/territorios`, mas com encaixe direto no sistema de listas existente (`CampaignPageShell` + `CampaignTable` + header sort/filtro do B15/B16)
|
||||
Appetite: ~1–1,5 dia eng; sem migration, sem collection, sem server action — rota + módulo de URL + definições de coluna + entrada no sidebar
|
||||
Responsável: —
|
||||
|
||||
## As built (2026-07-25)
|
||||
|
||||
- `/campanha/territorios` é RSC staff-only, com busca, filtros por TI/assessoria, sort, canonicalização e pending pela URL; `leader` redireciona para `/campanha`.
|
||||
- O contrato compartilhado `resolveListUrl` exige `page`; como esta lista fixa de 27 linhas não pagina, `resolveTerritoryListUrl` usa `inspectRawListParams` e não inventa `page: 1`.
|
||||
- `CampaignTable` renderiza linhas planas; `flattenTerritoryRows` transforma pai/sub-linhas num union e mantém as duas decomposições do Metropolitano adjacentes ao pai.
|
||||
- `CampaignSearchForm` descartaria sort/filtros; `TerritoryFilters` segue o padrão de `MunicipalityFilters`, preservando o estado e oferecendo controles mobile.
|
||||
- A extração disparada por B29/B33 foi antecipada: `shared/CampaignSortableHead` e `CampaignHeaderFilterPopover` contêm só chrome/a11y; wrappers de município e território mantêm política de domínio.
|
||||
- Por decisão desta sessão, o painel de TI foi **removido** do Início, junto com `territorySlot` e `TerritoryOverviewTable`; não há variante compacta.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 e 6; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`src/components/campaign/shared/CampaignTable.tsx`, `CampaignSearchForm`, `CampaignFilterChips`, `CampaignListFooter`, `CampaignListPendingBoundary`) · painel entregue no Início (`TerritoryOverviewTable`).
|
||||
|
|
@ -48,7 +57,7 @@ O pedido (2026-07-25) é fechar essa distância: uma **página própria** para o
|
|||
- **Filtro no header** (Popover, padrão B16) no conjunto mínimo que decide algo com 27 linhas: Território (multi-seleção) e Cobertura (`com_assessor` / `sem_assessor`), mais busca por nome; chips de filtro ativo + "Limpar" pelos shells compartilhados.
|
||||
- **Metropolitano decomposto** preservado (Salvador 19 zonas × Demais RMS) — salvaguarda permanente do relatório; sub-linhas nunca são reordenadas independentemente.
|
||||
- **Linha → lista filtrada** `/campanha/municipios?region=<TI>` (comportamento já entregue no E17).
|
||||
- **Uma única implementação da leitura por TI:** o painel do Início passa a consumir as mesmas definições de coluna (subconjunto compacto) e ganha um link "Ver todos os territórios" para a página. Sem segunda tabela divergente.
|
||||
- **Uma única implementação da leitura por TI:** a página dedicada substitui o painel do Início; a tabela antiga e seu slot foram removidos para não manter duas superfícies divergentes.
|
||||
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; leitura agregada com `overrideAccess: true` (precedente E17), nunca PII por município.
|
||||
|
||||
## Decisões travadas
|
||||
|
|
@ -63,7 +72,7 @@ O pedido (2026-07-25) é fechar essa distância: uma **página própria** para o
|
|||
|
||||
## Questões em aberto
|
||||
|
||||
- **O painel do Início continua?** **Opções:** (A) continua completo + link; (B) vira versão compacta (Território · % da votação · Com assessor) com "Ver todos"; (C) sai do Início. **Recomendação: B** — o Início volta a ser resumo e a página vira o lugar de trabalhar, sem perder o pedido do candidato (a leitura regional segue visível na home). Reaproveita as mesmas definições de coluna via subconjunto de `id`s. _(assumido — validar na primeira demo.)_
|
||||
- **O painel do Início continua?** **Resolvido em 2026-07-25: C — saiu do Início.** A página dedicada é o lugar de comparar TIs; a remoção também eliminou `territorySlot` e `TerritoryOverviewTable`.
|
||||
- **Entrada no sidebar: item próprio ou 2º nível sob Municípios?** **Opções:** item em `staffNav` logo abaixo de Municípios | submenu de Municípios (o padrão que o B18 planeja para filtros salvos). **Recomendação:** item próprio em `staffNav` (`MapIcon`), **fora** do `getCampaignBottomNav` (o bottom bar corta em 5 e Territórios não desloca Planos). O submenu do B18 é para estados salvos da mesma lista, não para outra entidade.
|
||||
- **Filtro de "peso" (ex.: só TIs acima de X% da votação)?** **Recomendação:** não na v1 — com 27 linhas, ordenar por % resolve; um filtro numérico é controle a mais sem decisão nova. Reavaliar se a mesa pedir corte fixo.
|
||||
|
||||
|
|
@ -124,6 +133,7 @@ Componentes:
|
|||
## Adiado com gatilho
|
||||
|
||||
- **Head/URL genéricos em `shared/`.** Gatilho **disparado em 2026-07-25**: o 3º call site é o **B29** (sort + filtro no header de `/campanha/liderancas` — [plano](ordenacao-filtros-lista-liderancas.md)), que **assume** a extração do par head/filtro para `shared/` e a migração de municípios. Se B21 entrar primeiro, duplica o head uma vez como descrito acima e o B29 migra os três; se B29 entrar primeiro, esta página já nasce consumindo o compartilhado. Os **módulos de URL** seguem um por domínio em qualquer ordem.
|
||||
- **Agregação SQL específica para a página.** O rollup atual lê 435 municípios e agrega os pledges vivos em memória a cada navegação de sort/filtro; isso é deliberado enquanto a cardinalidade é pequena e não existe invalidação segura para cache cross-request. Gatilho: p95 medido da rota ultrapassar 500 ms ou o volume de pledges tornar essa leitura material; então projetar somente os totais por cenário necessários no banco, sem cache de campanha sem invalidação.
|
||||
- **Filtros adicionais no header** (tendência agregada, faixa de peso). Gatilho: a mesa pedir um corte que a ordenação não resolve em sessão real.
|
||||
- **Coluna de cobertura de meta por TI.** Gatilho: **E12** entregue com a regra "razão dos agregados + decomposição obrigatória".
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · absorve os débitos de produto/UX remanescentes de FD2 ([field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md)): glossário inline (O3 — hipótese ainda sem evidência), triagem em lote, empty states de coordenador · gatilho: antes de 16/08
|
||||
- **B14** município mais próximo (geolocalização → atalho no Início staff) · pede permissão **1× por sessão** se ainda não concedida; matching client-side sobre centroides IBGE; Salvador multi-zona → lista filtrada até B8 F2 · ~1d · Janela 1–2 · sem deps duras · cortável · [plano](plans/municipio-mais-proximo.md)
|
||||
- ~~**B20** Municípios prioritários no Início~~ — **entregue 2026-07-25** (`pickDashboardPriorityMunicipalities` + amostra 8 por déficit `central`; Badge com total; “Ver todas” → `?priority=alta`; `list-none` na strip) · [plano](plans/municipios-prioritarios-inicio.md)
|
||||
- **B21** página própria dos Territórios de Identidade — rota staff `/campanha/territorios` com overview + tabela dos 27 TIs no sistema de listas (colunas como dado em `CampaignTable`, sort no header via URL B15, filtro em Popover B16, busca/chips/footer compartilhados); reusa o rollup do **E17 ✓** (`territoryOverview.ts`/`loadTerritoryOverview.ts`), Metropolitano decomposto, linha → `?region=<TI>`; painel do Início vira variante compacta com “Ver todos” (mesmas definições de coluna); **E12** passa a pousar suas métricas aqui em vez de abrir tela nova; sem migration/collection/action · ~1–1,5d · Janela 1–2 · Impeccable C · soft: E12/B17/B13 · cortável · [plano](plans/pagina-territorios-identidade.md)
|
||||
- ~~**B21** página própria dos Territórios de Identidade~~ — **entregue 2026-07-25** (`/campanha/territorios` staff-only; 27 TIs + decomposição do Metropolitano; sort/filtros/busca canônicos na URL; drill para municípios; chrome extraído em `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`; painel de TI removido do Início por decisão da sessão) · sem migration/collection/action · [plano](plans/pagina-territorios-identidade.md)
|
||||
- **Downstream atualizado:** B25 teve a dependência dura atendida; B29 e B33 não pagam mais a extração do chrome compartilhado e agora implementam apenas política/URL/filtros dos próprios domínios.
|
||||
- ~~**B15** ordenar lista de municípios pelo header da coluna~~ — **entregue 2026-07-24** (`?sort=`/`?dir=` na URL + clique no header (desktop) / select compacto (mobile); ordenação global no filtrado; **A11** consome o contrato (key `votos`)) · [plano](plans/ordenacao-colunas-lista-municipios.md)
|
||||
- ~~**B16** filtros no header das colunas da lista de municípios~~ — **entregue 2026-07-24** (funil+Popover no `TableHead` ao lado do sort B15; barra slim busca+resumo+Limpar; mobile selects empilhados; optimistic + busca no Popover de TI) · [plano](plans/filtros-no-header-lista-municipios.md)
|
||||
- **B22** explicação por coluna no header das listas — pedido de produto (2026-07-25): tooltip no hover de cada header de `/campanha/municipios` dizendo o que a coluna mede, **implementado no componente de header** para reuso. Hoje a capacidade existe pela metade (prop `tooltip` do `MunicipalitySortableHead`, usada em 3 de 9 colunas; primitivo preso ao domínio `municipality`; `CampaignTableHead` sem prop nenhuma): promover `MunicipalityHoverTooltip`→`shared/CampaignHoverTooltip`, prop `description` nos dois heads, copy das colunas de municípios com `oneLiner` do **E18 ✓** como fonte única nas métricas do E8; sem migration/action; dá evidência datada para o débito **O3**/glossário inline do R6 · ~0,5d · Janela 1–2 · Impeccable B · soft: B15 ✓/B16 ✓/E18 ✓ · cortável · [plano](plans/explicacao-colunas-header-listas.md)
|
||||
|
|
|
|||
|
|
@ -7,15 +7,12 @@ import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashb
|
|||
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
|
||||
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
|
||||
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
|
||||
import { TerritoryOverviewTable } from '@/components/campaign/municipality/TerritoryOverviewTable'
|
||||
import { campaignPrioritySurfaceClassName } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
|
||||
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
|
||||
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
|
||||
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
|
||||
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
|
@ -66,20 +63,6 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
|
|||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
territorySlot={
|
||||
isStaff ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-[280px] w-full animate-pulse rounded-xl border bg-muted/40"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TerritoryOverviewSection payload={payload} />
|
||||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -105,18 +88,3 @@ const DashboardMapSection = async ({
|
|||
</MunicipalityEstimateScenarioProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const TerritoryOverviewSection = async ({ payload }: { payload: Payload }) => {
|
||||
const rows = await loadTerritoryOverview(payload)
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="territory-overview-title"
|
||||
className={`rounded-xl border p-4 ${campaignPrioritySurfaceClassName}`}
|
||||
>
|
||||
<h2 id="territory-overview-title" className="mb-3 text-base font-medium">
|
||||
Territórios de Identidade
|
||||
</h2>
|
||||
<TerritoryOverviewTable rows={rows} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
68
src/app/(campaign)/campanha/(app)/territorios/page.tsx
Normal file
68
src/app/(campaign)/campanha/(app)/territorios/page.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import config from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
|
||||
import { TerritoryList } from '@/components/campaign/municipality/TerritoryList'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { isCampaignLeader } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
|
||||
import { resolveTerritoryListSort, resolveTerritoryListUrl } from '@/utilities/territoryListUrl'
|
||||
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territoryOverview'
|
||||
|
||||
type TerritoriesPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export default async function TerritoriesPage({ searchParams }: TerritoriesPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const resolvedUrl = resolveTerritoryListUrl(rawSearchParams)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
|
||||
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
|
||||
|
||||
if (!user) return null
|
||||
if (isCampaignLeader(user)) redirect('/campanha')
|
||||
|
||||
const allRows = await loadTerritoryOverview(payload)
|
||||
const { state } = resolvedUrl
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const rows = sortTerritoryRows(filterTerritoryRows(allRows, state), sort, dir)
|
||||
const regionOptions = allRows
|
||||
.map((row) => ({ value: row.region, label: row.region }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex max-w-prose flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
Territórios de Identidade
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-pretty">
|
||||
Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um
|
||||
território para ver seus municípios.
|
||||
</p>
|
||||
</header>
|
||||
<CampaignListPendingBoundary>
|
||||
<TerritoryFilters state={state} regionOptions={regionOptions} />
|
||||
<CampaignListResults>
|
||||
<TerritoryList rows={rows} state={state} regionOptions={regionOptions} />
|
||||
<CampaignListFooter
|
||||
totalDocs={rows.length}
|
||||
singular="território encontrado"
|
||||
plural="territórios encontrados"
|
||||
page={1}
|
||||
totalPages={1}
|
||||
hrefForPage={() => resolvedUrl.href}
|
||||
/>
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,14 +30,11 @@ export const CampaignDashboard = ({
|
|||
view,
|
||||
userName,
|
||||
mapSlot = null,
|
||||
territorySlot = null,
|
||||
}: {
|
||||
view: StaffDashboardView
|
||||
userName: string
|
||||
/** Server-streamed map section (composition keeps this component map-agnostic). */
|
||||
mapSlot?: ReactNode
|
||||
/** Server-streamed Territórios de Identidade comparative table (E17). */
|
||||
territorySlot?: ReactNode
|
||||
}) => (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-1">
|
||||
|
|
@ -85,8 +82,6 @@ export const CampaignDashboard = ({
|
|||
/>
|
||||
</section>
|
||||
|
||||
{territorySlot}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section
|
||||
aria-labelledby="dashboard-missing-title"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCampaignListPending } from '@/components/campaign/shared/CampaignLis
|
|||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
|
|
@ -33,44 +34,6 @@ import {
|
|||
|
||||
const SEARCH_DEBOUNCE_MS = 1000
|
||||
|
||||
/** Mobile stand-in for a header multi-select: pick to add, pick again to remove. */
|
||||
const MobileMultiFilterField = ({
|
||||
id,
|
||||
label,
|
||||
emptyLabel,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
emptyLabel: string
|
||||
options: MunicipalityFilterOption[]
|
||||
selected: string[]
|
||||
onToggle: (value: string) => void
|
||||
}) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{options.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
|
||||
type MunicipalityFiltersProps = {
|
||||
state: MunicipalityListState
|
||||
showStaffFilters: boolean
|
||||
|
|
@ -237,7 +200,7 @@ export const MunicipalityFilters = ({
|
|||
</Field>
|
||||
) : null}
|
||||
{regionFilterOptions.length ? (
|
||||
<MobileMultiFilterField
|
||||
<CampaignMobileMultiFilterField
|
||||
id="municipality-filter-region"
|
||||
label="Território"
|
||||
emptyLabel="Todos"
|
||||
|
|
@ -295,7 +258,7 @@ export const MunicipalityFilters = ({
|
|||
)
|
||||
})}
|
||||
{showStaffFilters ? (
|
||||
<MobileMultiFilterField
|
||||
<CampaignMobileMultiFilterField
|
||||
id="municipality-filter-trend"
|
||||
label="Tendência"
|
||||
emptyLabel="Todas"
|
||||
|
|
@ -313,7 +276,7 @@ export const MunicipalityFilters = ({
|
|||
/>
|
||||
) : null}
|
||||
{showStaffFilters && advisorFilterOptions.length ? (
|
||||
<MobileMultiFilterField
|
||||
<CampaignMobileMultiFilterField
|
||||
id="municipality-filter-advisor"
|
||||
label="Assessores"
|
||||
emptyLabel="Todos"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
|
||||
import { useMemo, useOptimistic, useState, type ReactNode } from 'react'
|
||||
import { useOptimistic } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import { municipalityPriorityLabels } from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
applyMunicipalityKindFilter,
|
||||
|
|
@ -44,9 +41,6 @@ type MunicipalityHeaderFilterProps = {
|
|||
showPriorityFilter?: boolean
|
||||
}
|
||||
|
||||
/** Above this many options the popover gets a search box. */
|
||||
const SEARCHABLE_OPTION_THRESHOLD = 8
|
||||
|
||||
const multiParamByFilter = {
|
||||
name: 'slug',
|
||||
region: 'region',
|
||||
|
|
@ -56,54 +50,12 @@ const multiParamByFilter = {
|
|||
coverage: null,
|
||||
} as const satisfies Record<MunicipalityFilterParam, MunicipalityMultiFilterParam | null>
|
||||
|
||||
const FilterOptionLink = ({
|
||||
href,
|
||||
selected,
|
||||
checkbox = false,
|
||||
onChoose,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
selected: boolean
|
||||
checkbox?: boolean
|
||||
onChoose: () => void
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<CampaignTransitionAnchor
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center rounded-md px-2 text-sm whitespace-normal hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
checkbox ? 'gap-2' : selected && 'bg-muted font-medium',
|
||||
)}
|
||||
onNavigate={onChoose}
|
||||
>
|
||||
{checkbox ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className={cn(selected && 'font-medium')}>{children}</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
|
||||
export const MunicipalityHeaderFilter = ({
|
||||
state,
|
||||
filterParam,
|
||||
options: optionsInput,
|
||||
showPriorityFilter = false,
|
||||
}: MunicipalityHeaderFilterProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
/**
|
||||
* Optimistic selection: set inside the navigation transition (see
|
||||
* `CampaignTransitionAnchor.onNavigate`), so React shows the clicked state
|
||||
|
|
@ -114,28 +66,15 @@ export const MunicipalityHeaderFilter = ({
|
|||
|
||||
const definition = getMunicipalityFilterDefinition(filterParam)
|
||||
const definitionOptions = definition.options
|
||||
const options = useMemo(() => {
|
||||
if (!optionsInput) return definitionOptions ?? []
|
||||
return typeof optionsInput[0] === 'string' || optionsInput.length === 0
|
||||
const options = !optionsInput
|
||||
? (definitionOptions ?? [])
|
||||
: typeof optionsInput[0] === 'string' || optionsInput.length === 0
|
||||
? municipalityFilterOptionsForSlugs(optionsInput as readonly string[])
|
||||
: (optionsInput as MunicipalityFilterOption[])
|
||||
}, [optionsInput, definitionOptions])
|
||||
const isActive = isMunicipalityColumnFilterActive(viewState, filterParam)
|
||||
const searchable = options.length >= SEARCHABLE_OPTION_THRESHOLD
|
||||
|
||||
// Normalizing 435 labels per keystroke is the cost to avoid here, not the filter itself.
|
||||
const searchIndex = useMemo(
|
||||
() => options.map((option) => ({ option, haystack: normalizeSearchPhrase(option.label) })),
|
||||
[options],
|
||||
)
|
||||
const needle = normalizeSearchPhrase(query)
|
||||
const visibleOptions = needle
|
||||
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.option)
|
||||
: options
|
||||
|
||||
const commit = (next: MunicipalityListState) => {
|
||||
setOptimisticState(next)
|
||||
if (definition.selection !== 'multi') setOpen(false)
|
||||
}
|
||||
|
||||
const multiParam = multiParamByFilter[filterParam]
|
||||
|
|
@ -173,143 +112,72 @@ export const MunicipalityHeaderFilter = ({
|
|||
: viewState
|
||||
|
||||
const hasSelection = selectedMulti.length > 0 || exclusiveRows.some((row) => row.selected)
|
||||
const showClear = hasSelection
|
||||
const sharedExclusiveRows: CampaignHeaderFilterRow[] = exclusiveRows.map((row) => ({
|
||||
value: row.label,
|
||||
label: row.label,
|
||||
href: buildMunicipalityFilterHref(row.next),
|
||||
selected: row.selected,
|
||||
checkbox: true,
|
||||
onChoose: () => commit(row.next),
|
||||
}))
|
||||
|
||||
const singleRows: CampaignHeaderFilterRow[] =
|
||||
definition.selection === 'single'
|
||||
? [
|
||||
...(definition.allLabel
|
||||
? [
|
||||
{
|
||||
value: '',
|
||||
label: definition.allLabel,
|
||||
href: buildMunicipalityFilterHref(
|
||||
applyMunicipalityKindFilter(viewState, undefined),
|
||||
),
|
||||
selected: !getMunicipalitySingleFilterValue(viewState, 'kind'),
|
||||
onChoose: () => commit(applyMunicipalityKindFilter(viewState, undefined)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...options.map((option) => {
|
||||
const next = applyMunicipalityKindFilter(viewState, option.value)
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildMunicipalityFilterHref(next),
|
||||
selected: getMunicipalitySingleFilterValue(viewState, 'kind') === option.value,
|
||||
onChoose: () => commit(next),
|
||||
}
|
||||
}),
|
||||
]
|
||||
: []
|
||||
|
||||
const multiRows: CampaignHeaderFilterRow[] = multiParam
|
||||
? options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildMunicipalityFilterOptionHref(viewState, multiParam, option.value),
|
||||
selected: selectedMulti.includes(option.value),
|
||||
checkbox: true,
|
||||
onChoose: () =>
|
||||
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value)),
|
||||
}))
|
||||
: []
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-11 w-7 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={
|
||||
isActive
|
||||
? `Filtrar ${definition.label}: ativo. Alterar filtro`
|
||||
: `Filtrar por ${definition.label}`
|
||||
}
|
||||
>
|
||||
{isActive ? (
|
||||
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<p
|
||||
id={`municipality-filter-heading-${filterParam}`}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{definition.label}
|
||||
</p>
|
||||
{showClear ? (
|
||||
<CampaignTransitionAnchor
|
||||
href={buildMunicipalityFilterHref(clearedState)}
|
||||
replace
|
||||
scroll={false}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onNavigate={() => commit(clearedState)}
|
||||
>
|
||||
Limpar
|
||||
</CampaignTransitionAnchor>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{exclusiveRows.length ? (
|
||||
<div className="mb-1 flex flex-col gap-0.5 border-b border-border px-1 pb-1">
|
||||
{exclusiveRows.map((row) => (
|
||||
<FilterOptionLink
|
||||
key={row.label}
|
||||
href={buildMunicipalityFilterHref(row.next)}
|
||||
selected={row.selected}
|
||||
checkbox
|
||||
onChoose={() => commit(row.next)}
|
||||
>
|
||||
{row.label}
|
||||
</FilterOptionLink>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchable ? (
|
||||
<div className="px-1 pb-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar…"
|
||||
aria-label={`Buscar em ${definition.label}`}
|
||||
className="min-h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-labelledby={`municipality-filter-heading-${filterParam}`}
|
||||
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
{definition.selection === 'single' && definition.allLabel ? (
|
||||
<FilterOptionLink
|
||||
href={buildMunicipalityFilterHref(applyMunicipalityKindFilter(viewState, undefined))}
|
||||
selected={!getMunicipalitySingleFilterValue(viewState, 'kind')}
|
||||
onChoose={() => commit(applyMunicipalityKindFilter(viewState, undefined))}
|
||||
>
|
||||
{definition.allLabel}
|
||||
</FilterOptionLink>
|
||||
) : null}
|
||||
|
||||
{/* Tipo is the only single-select column; com/sem assessor rides along
|
||||
as an exclusive row of the Assessores column. */}
|
||||
{definition.selection === 'single'
|
||||
? visibleOptions.map((option) => {
|
||||
const next = applyMunicipalityKindFilter(viewState, option.value)
|
||||
return (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterHref(next)}
|
||||
selected={getMunicipalitySingleFilterValue(viewState, 'kind') === option.value}
|
||||
onChoose={() => commit(next)}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
|
||||
{/* Option hrefs ride the cheap canonical-toggle serializer; the full
|
||||
toggle (state for the optimistic commit) runs only on click. */}
|
||||
{multiParam
|
||||
? visibleOptions.map((option) => (
|
||||
<FilterOptionLink
|
||||
key={option.value}
|
||||
href={buildMunicipalityFilterOptionHref(viewState, multiParam, option.value)}
|
||||
selected={selectedMulti.includes(option.value)}
|
||||
checkbox
|
||||
onChoose={() =>
|
||||
commit(toggleMunicipalityMultiFilterValue(viewState, multiParam, option.value))
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</FilterOptionLink>
|
||||
))
|
||||
: null}
|
||||
|
||||
{visibleOptions.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{needle ? 'Nenhum resultado encontrado.' : 'Nenhuma opção no escopo filtrado.'}
|
||||
</p>
|
||||
) : null}
|
||||
</nav>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<CampaignHeaderFilterPopover
|
||||
id={`municipality-filter-${filterParam}`}
|
||||
label={definition.label}
|
||||
active={isActive}
|
||||
closeOnChoose={definition.selection !== 'multi'}
|
||||
exclusiveRows={sharedExclusiveRows}
|
||||
optionRows={definition.selection === 'single' ? singleRows : multiRows}
|
||||
clear={
|
||||
hasSelection
|
||||
? {
|
||||
href: buildMunicipalityFilterHref(clearedState),
|
||||
onChoose: () => commit(clearedState),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { MunicipalityHeaderFilter } from '@/components/campaign/municipality/MunicipalityHeaderFilter'
|
||||
import { MunicipalityHoverTooltip } from '@/components/campaign/municipality/MunicipalityHoverTooltip'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { TableHead } from '@/components/ui/Table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
|
||||
import type {
|
||||
MunicipalityFilterOption,
|
||||
MunicipalityFilterParam,
|
||||
|
|
@ -49,89 +46,44 @@ export const MunicipalitySortableHead = ({
|
|||
const { sort: activeSort, dir } = resolveMunicipalityListSort(state)
|
||||
const active = activeSort === sortKey
|
||||
const label = children ?? municipalityListSortLabels[sortKey]
|
||||
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
|
||||
const nextDir = active
|
||||
? dir === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: defaultMunicipalityListSortDir(sortKey)
|
||||
const nextDirLabel = nextDir === 'asc' ? 'crescente' : 'decrescente'
|
||||
|
||||
const sortControl = (
|
||||
<CampaignTransitionAnchor
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(
|
||||
'group inline-flex min-h-11 items-center gap-1 rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'hover:text-foreground',
|
||||
active ? 'font-medium' : 'font-normal',
|
||||
align === 'right' && !filterParam && 'w-full justify-end',
|
||||
align === 'center' && !filterParam && 'w-full justify-center',
|
||||
)}
|
||||
aria-label={
|
||||
active
|
||||
? `Ordenar por ${municipalityListSortLabels[sortKey]}, inverter para ${nextDirLabel}`
|
||||
: `Ordenar por ${municipalityListSortLabels[sortKey]} (${nextDirLabel})`
|
||||
}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active ? (
|
||||
dir === 'asc' ? (
|
||||
<ChevronUpIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDownIcon
|
||||
className="size-3.5 shrink-0 opacity-60 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
|
||||
const labeledSort = tooltip ? (
|
||||
<MunicipalityHoverTooltip
|
||||
content={tooltip}
|
||||
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||
>
|
||||
{sortControl}
|
||||
</MunicipalityHoverTooltip>
|
||||
) : (
|
||||
sortControl
|
||||
)
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
aria-sort={ariaSort}
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'left' && 'text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{filterParam ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'center' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{labeledSort}
|
||||
<CampaignSortableHead
|
||||
active={active}
|
||||
align={align}
|
||||
className={className}
|
||||
dir={dir}
|
||||
href={href}
|
||||
nextDir={nextDir}
|
||||
sortLabel={municipalityListSortLabels[sortKey]}
|
||||
filter={
|
||||
filterParam ? (
|
||||
<MunicipalityHeaderFilter
|
||||
state={state}
|
||||
filterParam={filterParam}
|
||||
options={filterOptions}
|
||||
showPriorityFilter={showPriorityFilter}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
labeledSort
|
||||
)}
|
||||
</TableHead>
|
||||
) : null
|
||||
}
|
||||
wrapSortControl={
|
||||
tooltip
|
||||
? (control) => (
|
||||
<MunicipalityHoverTooltip
|
||||
content={tooltip}
|
||||
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||
>
|
||||
{control}
|
||||
</MunicipalityHoverTooltip>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</CampaignSortableHead>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
193
src/components/campaign/municipality/TerritoryFilters.tsx
Normal file
193
src/components/campaign/municipality/TerritoryFilters.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { normalizedText } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
clearTerritoryListFilters,
|
||||
formatTerritoryActiveFiltersSummary,
|
||||
territoryCoverageLabels,
|
||||
toggleTerritoryRegionFilter,
|
||||
type TerritoryFilterOption,
|
||||
} from '@/utilities/territoryListFilters'
|
||||
import {
|
||||
buildTerritoryListHref,
|
||||
parseTerritoryListParams,
|
||||
parseTerritorySortValue,
|
||||
resolveTerritoryListSort,
|
||||
serializeTerritorySortValue,
|
||||
territoryListSortOptions,
|
||||
territoryListStateToRawParams,
|
||||
type TerritoryListState,
|
||||
} from '@/utilities/territoryListUrl'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 1000
|
||||
|
||||
export const TerritoryFilters = ({
|
||||
state,
|
||||
regionOptions,
|
||||
}: {
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState(state.q ?? '')
|
||||
const sharedPending = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
const isPending = sharedPending?.isPending ?? isLocalPending
|
||||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const activeSummary = formatTerritoryActiveFiltersSummary({
|
||||
...state,
|
||||
q: normalizedText(search) || state.q,
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const clearDebounce = () => {
|
||||
if (!debounceRef.current) return
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
|
||||
const navigateTo = (next: TerritoryListState) => {
|
||||
clearDebounce()
|
||||
const canonical = parseTerritoryListParams(
|
||||
territoryListStateToRawParams({ ...next, q: normalizedText(next.q) }),
|
||||
)
|
||||
if (buildTerritoryListHref(canonical) === buildTerritoryListHref(state)) return
|
||||
startTransition(() => {
|
||||
router.replace(buildTerritoryListHref(canonical), { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleSearch = (value: string) => {
|
||||
clearDebounce()
|
||||
if (normalizedText(value) === state.q) return
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null
|
||||
navigateTo({ ...state, q: value })
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
navigateTo({ ...state, q: search })
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<CampaignSearchInput
|
||||
id="territory-search"
|
||||
label="Buscar território"
|
||||
placeholder="Buscar por território…"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
scheduleSearch(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{activeSummary ? (
|
||||
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
|
||||
{activeSummary}
|
||||
</p>
|
||||
) : null}
|
||||
{activeSummary ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 md:self-end"
|
||||
onClick={() => {
|
||||
clearDebounce()
|
||||
setSearch('')
|
||||
navigateTo(clearTerritoryListFilters(state))
|
||||
}}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
|
||||
<CampaignMobileMultiFilterField
|
||||
id="territory-filter-region"
|
||||
label="Território"
|
||||
emptyLabel="Todos"
|
||||
options={regionOptions}
|
||||
selected={state.regions ?? []}
|
||||
onToggle={(value) => navigateTo(toggleTerritoryRegionFilter(state, value))}
|
||||
/>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="territory-filter-coverage">Assessoria</FieldLabel>
|
||||
<NativeSelect
|
||||
id="territory-filter-coverage"
|
||||
value={state.coverage ?? ''}
|
||||
onChange={(event) =>
|
||||
navigateTo({
|
||||
...state,
|
||||
coverage:
|
||||
event.target.value === 'com_assessor' || event.target.value === 'sem_assessor'
|
||||
? event.target.value
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full [&_select]:h-11"
|
||||
>
|
||||
<NativeSelectOption value="">Todas</NativeSelectOption>
|
||||
{Object.entries(territoryCoverageLabels).map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="territory-sort">Ordenar</FieldLabel>
|
||||
<NativeSelect
|
||||
id="territory-sort"
|
||||
value={serializeTerritorySortValue(sort, dir)}
|
||||
onChange={(event) => {
|
||||
const parsed = parseTerritorySortValue(event.target.value)
|
||||
if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir })
|
||||
}}
|
||||
className="w-full [&_select]:h-11"
|
||||
>
|
||||
{territoryListSortOptions.map((option) => (
|
||||
<NativeSelectOption
|
||||
key={serializeTerritorySortValue(option.key, option.dir)}
|
||||
value={serializeTerritorySortValue(option.key, option.dir)}
|
||||
>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use client'
|
||||
|
||||
import { useOptimistic } from 'react'
|
||||
|
||||
import {
|
||||
CampaignHeaderFilterPopover,
|
||||
type CampaignHeaderFilterRow,
|
||||
} from '@/components/campaign/shared/CampaignHeaderFilterPopover'
|
||||
import {
|
||||
isTerritoryFilterActive,
|
||||
territoryCoverageOptions,
|
||||
toggleTerritoryCoverageFilter,
|
||||
toggleTerritoryRegionFilter,
|
||||
type TerritoryFilterOption,
|
||||
} from '@/utilities/territoryListFilters'
|
||||
import { buildTerritoryListHref, type TerritoryListState } from '@/utilities/territoryListUrl'
|
||||
|
||||
type TerritoryHeaderFilterProps =
|
||||
| {
|
||||
state: TerritoryListState
|
||||
filterParam: 'region'
|
||||
options: TerritoryFilterOption[]
|
||||
}
|
||||
| {
|
||||
state: TerritoryListState
|
||||
filterParam: 'coverage'
|
||||
options?: never
|
||||
}
|
||||
|
||||
export const TerritoryHeaderFilter = ({
|
||||
state,
|
||||
filterParam,
|
||||
options,
|
||||
}: TerritoryHeaderFilterProps) => {
|
||||
const [viewState, setOptimisticState] = useOptimistic(state)
|
||||
const active = isTerritoryFilterActive(viewState, filterParam)
|
||||
|
||||
const rows: CampaignHeaderFilterRow[] =
|
||||
filterParam === 'region'
|
||||
? options.map((option) => {
|
||||
const next = toggleTerritoryRegionFilter(viewState, option.value)
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildTerritoryListHref(next),
|
||||
selected: Boolean(viewState.regions?.some((region) => region === option.value)),
|
||||
checkbox: true,
|
||||
onChoose: () => setOptimisticState(next),
|
||||
}
|
||||
})
|
||||
: territoryCoverageOptions.map((option) => {
|
||||
const next = toggleTerritoryCoverageFilter(viewState, option.value)
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
href: buildTerritoryListHref(next),
|
||||
selected: viewState.coverage === option.value,
|
||||
checkbox: false,
|
||||
onChoose: () => setOptimisticState(next),
|
||||
}
|
||||
})
|
||||
|
||||
const clearedState: TerritoryListState =
|
||||
filterParam === 'region'
|
||||
? { ...viewState, regions: undefined }
|
||||
: { ...viewState, coverage: undefined }
|
||||
|
||||
return (
|
||||
<CampaignHeaderFilterPopover
|
||||
id={`territory-filter-${filterParam}`}
|
||||
label={filterParam === 'region' ? 'Território' : 'Assessoria'}
|
||||
active={active}
|
||||
closeOnChoose={filterParam === 'coverage'}
|
||||
optionRows={rows}
|
||||
clear={
|
||||
active
|
||||
? {
|
||||
href: buildTerritoryListHref(clearedState),
|
||||
onChoose: () => setOptimisticState(clearedState),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
84
src/components/campaign/municipality/TerritoryList.tsx
Normal file
84
src/components/campaign/municipality/TerritoryList.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { SearchXIcon } from 'lucide-react'
|
||||
|
||||
import { territoryListColumns } from '@/components/campaign/municipality/TerritoryListColumns'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignTable } from '@/components/campaign/shared/CampaignTable'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
clearTerritoryListFilters,
|
||||
type TerritoryFilterOption,
|
||||
} from '@/utilities/territoryListFilters'
|
||||
import {
|
||||
buildTerritoryListHref,
|
||||
formatTerritoryListSortSummary,
|
||||
resolveTerritoryListSort,
|
||||
type TerritoryListState,
|
||||
} from '@/utilities/territoryListUrl'
|
||||
import { flattenTerritoryRows, type TerritoryOverviewRow } from '@/utilities/territoryOverview'
|
||||
|
||||
const TerritoryListEmptyState = ({ state }: { state: TerritoryListState }) => (
|
||||
<Empty className="min-h-56 w-64 sm:w-80 md:w-auto">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum território encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros para comparar os territórios.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<CampaignTransitionAnchor
|
||||
href={buildTerritoryListHref(clearTerritoryListFilters(state))}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
>
|
||||
Limpar busca e filtros
|
||||
</CampaignTransitionAnchor>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
export const TerritoryList = ({
|
||||
rows,
|
||||
state,
|
||||
regionOptions,
|
||||
}: {
|
||||
rows: TerritoryOverviewRow[]
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
}) => {
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const sortSummary = formatTerritoryListSortSummary(sort, dir)
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{sortSummary}
|
||||
</p>
|
||||
<CampaignTable
|
||||
caption={`${sortSummary}. Comparação dos Territórios de Identidade. Leitura regional; a alocação é decidida por município.`}
|
||||
className="overflow-visible"
|
||||
containerClassName="overflow-x-auto"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
columns={territoryListColumns({ state, regionOptions })}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) =>
|
||||
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
|
||||
}
|
||||
rowClassName={(row) => (row.variant === 'sub' ? 'bg-muted/30' : undefined)}
|
||||
empty={<TerritoryListEmptyState state={state} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
149
src/components/campaign/municipality/TerritoryListColumns.tsx
Normal file
149
src/components/campaign/municipality/TerritoryListColumns.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { TerritorySortableHead } from '@/components/campaign/municipality/TerritorySortableHead'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
|
||||
import type { TerritoryListState } from '@/utilities/territoryListUrl'
|
||||
import type { TerritoryTableRow } from '@/utilities/territoryOverview'
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%`
|
||||
|
||||
const YearSeriesCell = ({ votesByYear }: { votesByYear: Record<string, number> }) => {
|
||||
const prior = [
|
||||
{ year: '2014', value: votesByYear['2014'] ?? 0 },
|
||||
{ year: '2018', value: votesByYear['2018'] ?? 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<details className="group inline-flex flex-row-reverse items-center gap-1">
|
||||
<summary
|
||||
className="cursor-pointer list-none tabular-nums"
|
||||
aria-label={`Votos em 2022: ${formatElectionNumber(votesByYear['2022'] ?? 0)}. Abrir série de 2014 e 2018`}
|
||||
>
|
||||
{formatElectionNumber(votesByYear['2022'] ?? 0)}
|
||||
</summary>
|
||||
<span className="hidden whitespace-nowrap text-xs text-muted-foreground group-open:inline">
|
||||
{prior.map(({ year, value }) => `${year}: ${formatElectionNumber(value)}`).join(' · ')}
|
||||
</span>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const CoverageCell = ({
|
||||
withAdvisorCount,
|
||||
total,
|
||||
muted,
|
||||
}: {
|
||||
withAdvisorCount: number
|
||||
total: number
|
||||
muted: boolean
|
||||
}) => {
|
||||
if (total === 0) return <span className="text-muted-foreground">—</span>
|
||||
if (withAdvisorCount === 0) {
|
||||
return <Badge variant="estimate-pending">0 de {total}</Badge>
|
||||
}
|
||||
return (
|
||||
<span className={muted ? 'tabular-nums text-muted-foreground' : 'tabular-nums'}>
|
||||
{withAdvisorCount} de {total}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const territoryListColumns = ({
|
||||
state,
|
||||
regionOptions,
|
||||
}: {
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
}): Array<CampaignTableColumn<TerritoryTableRow>> => [
|
||||
{
|
||||
id: 'region',
|
||||
mandatory: true,
|
||||
defaultVisible: true,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={regionOptions}
|
||||
className="sticky left-0 z-20 min-w-56 bg-background"
|
||||
/>
|
||||
),
|
||||
cell: (row) =>
|
||||
row.variant === 'parent' ? (
|
||||
<CampaignTransitionAnchor
|
||||
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.region}
|
||||
</CampaignTransitionAnchor>
|
||||
) : (
|
||||
<span className="pl-4 text-muted-foreground">{row.label}</span>
|
||||
),
|
||||
cellClassName: (row) =>
|
||||
`sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`,
|
||||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
defaultVisible: true,
|
||||
head: <TerritorySortableHead state={state} sortKey="municipalities" align="right" />,
|
||||
cell: (row) => row.municipalityCount,
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
},
|
||||
{
|
||||
id: 'votes2022',
|
||||
defaultVisible: true,
|
||||
head: <TerritorySortableHead state={state} sortKey="votes2022" align="right" />,
|
||||
cell: (row) => <YearSeriesCell votesByYear={row.votesByYear} />,
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
},
|
||||
{
|
||||
id: 'pct',
|
||||
defaultVisible: true,
|
||||
head: <TerritorySortableHead state={state} sortKey="pct" align="right" />,
|
||||
cell: (row) => formatPercent(row.pctPropriaVotacao),
|
||||
cellClassName: (row) =>
|
||||
`text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`,
|
||||
},
|
||||
{
|
||||
id: 'validVotes2022',
|
||||
defaultVisible: true,
|
||||
head: <TerritorySortableHead state={state} sortKey="validVotes2022" align="right" />,
|
||||
cell: (row) => formatElectionNumber(row.validVotes2022),
|
||||
cellClassName: 'text-right tabular-nums text-muted-foreground',
|
||||
},
|
||||
{
|
||||
id: 'estimate2026',
|
||||
defaultVisible: true,
|
||||
head: <TerritorySortableHead state={state} sortKey="estimate2026" align="right" />,
|
||||
cell: (row) => formatElectionNumber(row.estimate2026),
|
||||
cellClassName: (row) =>
|
||||
`text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
defaultVisible: true,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
align="right"
|
||||
filterParam="coverage"
|
||||
/>
|
||||
),
|
||||
cell: (row) => (
|
||||
<CoverageCell
|
||||
withAdvisorCount={row.withAdvisorCount}
|
||||
total={row.municipalityCount}
|
||||
muted={row.variant === 'sub'}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
},
|
||||
]
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import {
|
||||
sortTerritoryRows,
|
||||
type TerritoryOverviewRow,
|
||||
type TerritorySortDir,
|
||||
type TerritorySortKey,
|
||||
} from '@/utilities/territoryOverview'
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const formatPercent = (ratio: number): string => `${percentFormatter.format(ratio * 100)}%`
|
||||
|
||||
type ColumnDef = {
|
||||
key: TerritorySortKey
|
||||
label: string
|
||||
align: 'left' | 'right'
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnDef[] = [
|
||||
{ key: 'region', label: 'Território', align: 'left' },
|
||||
{ key: 'municipalities', label: 'Municípios', align: 'right' },
|
||||
{ key: 'votes2022', label: 'Votos 2022', align: 'right' },
|
||||
{ key: 'pct', label: '% da votação', align: 'right' },
|
||||
{ key: 'validVotes2022', label: 'Válidos 2022', align: 'right' },
|
||||
{ key: 'estimate2026', label: 'Estimativa 2026', align: 'right' },
|
||||
{ key: 'coverage', label: 'Com assessor', align: 'right' },
|
||||
]
|
||||
|
||||
type TerritoryOverviewTableProps = {
|
||||
rows: TerritoryOverviewRow[]
|
||||
}
|
||||
|
||||
export const TerritoryOverviewTable = ({ rows }: TerritoryOverviewTableProps) => {
|
||||
const [sortKey, setSortKey] = useState<TerritorySortKey>('pct')
|
||||
const [sortDir, setSortDir] = useState<TerritorySortDir>('desc')
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => sortTerritoryRows(rows, sortKey, sortDir),
|
||||
[rows, sortKey, sortDir],
|
||||
)
|
||||
|
||||
const toggleSort = (key: TerritorySortKey) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir(key === 'region' ? 'asc' : 'desc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortIcon = (key: TerritorySortKey) => {
|
||||
if (key !== sortKey)
|
||||
return <ChevronsUpDownIcon className="text-muted-foreground/50" aria-hidden="true" />
|
||||
return sortDir === 'asc' ? (
|
||||
<ChevronUpIcon aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{COLUMNS.map((column) => {
|
||||
const isActive = column.key === sortKey
|
||||
return (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
aria-sort={isActive ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={column.align === 'right' ? 'text-right' : 'text-left'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(column.key)}
|
||||
className={`inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground ${column.align === 'right' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
{sortIcon(column.key)}
|
||||
{column.label}
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedRows.map((row) => (
|
||||
<TerritoryRow key={row.region} row={row} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leitura regional; alocação decide-se por município. % da votação = votos do TI ÷ votos
|
||||
estaduais (2022).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TerritoryRow = ({ row }: { row: TerritoryOverviewRow }) => (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.region}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.municipalityCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<YearSeriesCell votesByYear={row.votesByYear} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{formatPercent(row.pctPropriaVotacao)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(row.validVotes2022)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatElectionNumber(row.estimate2026)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<CoverageCell withAdvisorCount={row.withAdvisorCount} total={row.municipalityCount} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{row.subRows?.map((subRow) => (
|
||||
<TableRow key={`${row.region}-${subRow.label}`} className="bg-muted/30">
|
||||
<TableCell className="pl-6 text-muted-foreground">{subRow.label}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{subRow.municipalityCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<YearSeriesCell votesByYear={subRow.votesByYear} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatPercent(subRow.pctPropriaVotacao)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(subRow.validVotes2022)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(subRow.estimate2026)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<CoverageCell
|
||||
withAdvisorCount={subRow.withAdvisorCount}
|
||||
total={subRow.municipalityCount}
|
||||
muted
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const YearSeriesCell = ({ votesByYear }: { votesByYear: Record<string, number> }) => {
|
||||
const votes2022 = votesByYear['2022'] ?? 0
|
||||
const prior = [
|
||||
{ year: '2014', value: votesByYear['2014'] ?? 0 },
|
||||
{ year: '2018', value: votesByYear['2018'] ?? 0 },
|
||||
]
|
||||
return (
|
||||
<details className="group inline-flex flex-row-reverse items-center gap-1">
|
||||
<summary className="cursor-pointer list-none tabular-nums" aria-label="Série 2014 e 2018">
|
||||
{formatElectionNumber(votes2022)}
|
||||
</summary>
|
||||
<span className="hidden text-xs text-muted-foreground group-open:inline">
|
||||
{prior.map((p) => `${p.year}: ${formatElectionNumber(p.value)}`).join(' · ')}
|
||||
</span>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const CoverageCell = ({
|
||||
withAdvisorCount,
|
||||
total,
|
||||
muted = false,
|
||||
}: {
|
||||
withAdvisorCount: number
|
||||
total: number
|
||||
muted?: boolean
|
||||
}) => {
|
||||
if (total === 0) return <span className="text-muted-foreground">—</span>
|
||||
if (withAdvisorCount === 0) {
|
||||
return <Badge variant="estimate-pending">0 de {total}</Badge>
|
||||
}
|
||||
return (
|
||||
<span className={muted ? 'text-muted-foreground tabular-nums' : 'tabular-nums'}>
|
||||
{withAdvisorCount} de {total}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { TerritoryHeaderFilter } from '@/components/campaign/municipality/TerritoryHeaderFilter'
|
||||
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territoryListFilters'
|
||||
import {
|
||||
buildTerritorySortHref,
|
||||
defaultTerritoryListSortDir,
|
||||
resolveTerritoryListSort,
|
||||
territoryListSortLabels,
|
||||
type TerritoryListSortKey,
|
||||
type TerritoryListState,
|
||||
} from '@/utilities/territoryListUrl'
|
||||
|
||||
type TerritorySortableHeadProps = {
|
||||
state: TerritoryListState
|
||||
sortKey: TerritoryListSortKey
|
||||
children?: ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
} & (
|
||||
| {
|
||||
filterParam: 'region'
|
||||
filterOptions: TerritoryFilterOption[]
|
||||
}
|
||||
| {
|
||||
filterParam: 'coverage'
|
||||
filterOptions?: never
|
||||
}
|
||||
| {
|
||||
filterParam?: never
|
||||
filterOptions?: never
|
||||
}
|
||||
)
|
||||
|
||||
export const TerritorySortableHead = ({
|
||||
state,
|
||||
sortKey,
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
filterParam,
|
||||
filterOptions,
|
||||
}: TerritorySortableHeadProps) => {
|
||||
const { sort: activeSort, dir } = resolveTerritoryListSort(state)
|
||||
const active = activeSort === sortKey
|
||||
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultTerritoryListSortDir(sortKey)
|
||||
|
||||
return (
|
||||
<CampaignSortableHead
|
||||
active={active}
|
||||
align={align}
|
||||
className={className}
|
||||
dir={dir}
|
||||
href={buildTerritorySortHref(state, sortKey)}
|
||||
nextDir={nextDir}
|
||||
sortLabel={territoryListSortLabels[sortKey]}
|
||||
filter={
|
||||
filterParam === 'region' ? (
|
||||
<TerritoryHeaderFilter state={state} filterParam="region" options={filterOptions} />
|
||||
) : filterParam === 'coverage' ? (
|
||||
<TerritoryHeaderFilter state={state} filterParam="coverage" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{children ?? territoryListSortLabels[sortKey]}
|
||||
</CampaignSortableHead>
|
||||
)
|
||||
}
|
||||
173
src/components/campaign/shared/CampaignHeaderFilterPopover.tsx
Normal file
173
src/components/campaign/shared/CampaignHeaderFilterPopover.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use client'
|
||||
|
||||
import { FunnelIcon, FunnelPlusIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
export type CampaignHeaderFilterRow = {
|
||||
value: string
|
||||
label: string
|
||||
href: string
|
||||
selected: boolean
|
||||
checkbox?: boolean
|
||||
onChoose: () => void
|
||||
}
|
||||
|
||||
type CampaignHeaderFilterPopoverProps = {
|
||||
active: boolean
|
||||
clear?: { href: string; onChoose: () => void }
|
||||
closeOnChoose?: boolean
|
||||
emptyLabel?: string
|
||||
exclusiveRows?: CampaignHeaderFilterRow[]
|
||||
id: string
|
||||
label: string
|
||||
optionRows: CampaignHeaderFilterRow[]
|
||||
}
|
||||
|
||||
const SEARCHABLE_OPTION_THRESHOLD = 8
|
||||
|
||||
const FilterRow = ({ row, onChoose }: { row: CampaignHeaderFilterRow; onChoose: () => void }) => (
|
||||
<CampaignTransitionAnchor
|
||||
href={row.href}
|
||||
replace
|
||||
scroll={false}
|
||||
aria-current={row.selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center rounded-md px-2 text-sm whitespace-normal hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
row.checkbox ? 'gap-2' : row.selected && 'bg-muted font-medium',
|
||||
)}
|
||||
onNavigate={onChoose}
|
||||
>
|
||||
{row.checkbox ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={row.selected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className={cn(row.selected && 'font-medium')}>{row.label}</span>
|
||||
</>
|
||||
) : (
|
||||
row.label
|
||||
)}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
|
||||
export const CampaignHeaderFilterPopover = ({
|
||||
active,
|
||||
clear,
|
||||
closeOnChoose = false,
|
||||
emptyLabel = 'Nenhuma opção no escopo filtrado.',
|
||||
exclusiveRows = [],
|
||||
id,
|
||||
label,
|
||||
optionRows,
|
||||
}: CampaignHeaderFilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchable = optionRows.length >= SEARCHABLE_OPTION_THRESHOLD
|
||||
const searchIndex = useMemo(
|
||||
() =>
|
||||
optionRows.map((row) => ({
|
||||
row,
|
||||
haystack: normalizeSearchPhrase(row.label),
|
||||
})),
|
||||
[optionRows],
|
||||
)
|
||||
const needle = normalizeSearchPhrase(query)
|
||||
const visibleRows = needle
|
||||
? searchIndex.filter((entry) => entry.haystack.includes(needle)).map((entry) => entry.row)
|
||||
: optionRows
|
||||
|
||||
const choose = (row: CampaignHeaderFilterRow) => {
|
||||
row.onChoose()
|
||||
if (closeOnChoose) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) setQuery('')
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-md hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-label={active ? `Filtrar ${label}: ativo. Alterar filtro` : `Filtrar por ${label}`}
|
||||
>
|
||||
{active ? (
|
||||
<FunnelPlusIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<FunnelIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<p id={`${id}-heading`} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{clear ? (
|
||||
<CampaignTransitionAnchor
|
||||
href={clear.href}
|
||||
replace
|
||||
scroll={false}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onNavigate={clear.onChoose}
|
||||
>
|
||||
Limpar
|
||||
</CampaignTransitionAnchor>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{exclusiveRows.length ? (
|
||||
<div className="mb-1 flex flex-col gap-0.5 border-b border-border px-1 pb-1">
|
||||
{exclusiveRows.map((row) => (
|
||||
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchable ? (
|
||||
<div className="px-1 pb-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar…"
|
||||
aria-label={`Buscar em ${label}`}
|
||||
className="min-h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-labelledby={`${id}-heading`}
|
||||
className="flex max-h-72 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
{visibleRows.map((row) => (
|
||||
<FilterRow key={row.value} row={row} onChoose={() => choose(row)} />
|
||||
))}
|
||||
{visibleRows.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">
|
||||
{needle ? 'Nenhum resultado encontrado.' : emptyLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</nav>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
|
||||
type CampaignMobileMultiFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CampaignMobileMultiFilterField = ({
|
||||
id,
|
||||
label,
|
||||
emptyLabel,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
emptyLabel: string
|
||||
options: CampaignMobileMultiFilterOption[]
|
||||
selected: readonly string[]
|
||||
onToggle: (value: string) => void
|
||||
}) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{options.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selected.includes(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
102
src/components/campaign/shared/CampaignSortableHead.tsx
Normal file
102
src/components/campaign/shared/CampaignSortableHead.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { TableHead } from '@/components/ui/Table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CampaignSortableHeadProps = {
|
||||
active: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
children: ReactNode
|
||||
className?: string
|
||||
dir: 'asc' | 'desc'
|
||||
filter?: ReactNode
|
||||
href: string
|
||||
nextDir: 'asc' | 'desc'
|
||||
sortLabel: string
|
||||
wrapSortControl?: (
|
||||
control: ReactElement<{
|
||||
onPointerUp?: (event: PointerEvent) => void
|
||||
ref?: Ref<HTMLElement>
|
||||
}>,
|
||||
) => ReactNode
|
||||
}
|
||||
|
||||
export const CampaignSortableHead = ({
|
||||
active,
|
||||
align = 'left',
|
||||
children,
|
||||
className,
|
||||
dir,
|
||||
filter,
|
||||
href,
|
||||
nextDir,
|
||||
sortLabel,
|
||||
wrapSortControl,
|
||||
}: CampaignSortableHeadProps) => {
|
||||
const sortControl = (
|
||||
<CampaignTransitionAnchor
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(
|
||||
'group inline-flex min-h-11 items-center gap-1 rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'hover:text-foreground',
|
||||
active ? 'font-medium' : 'font-normal',
|
||||
align === 'right' && !filter && 'w-full justify-end',
|
||||
align === 'center' && !filter && 'w-full justify-center',
|
||||
)}
|
||||
aria-label={
|
||||
active
|
||||
? `Ordenar por ${sortLabel}, inverter para ${nextDir === 'asc' ? 'crescente' : 'decrescente'}`
|
||||
: `Ordenar por ${sortLabel} (${nextDir === 'asc' ? 'crescente' : 'decrescente'})`
|
||||
}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? (
|
||||
dir === 'asc' ? (
|
||||
<ChevronUpIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDownIcon
|
||||
className="size-3.5 shrink-0 opacity-60 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
const labeledSort = wrapSortControl ? wrapSortControl(sortControl) : sortControl
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
align === 'left' && 'text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{filter ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'center' && 'justify-center',
|
||||
)}
|
||||
>
|
||||
{labeledSort}
|
||||
{filter}
|
||||
</div>
|
||||
) : (
|
||||
labeledSort
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
HandshakeIcon,
|
||||
HomeIcon,
|
||||
InboxIcon,
|
||||
MapIcon,
|
||||
MapPinIcon,
|
||||
UserCogIcon,
|
||||
Users2Icon,
|
||||
|
|
@ -24,6 +25,7 @@ export type CampaignNavItem = {
|
|||
const staffNav: CampaignNavItem[] = [
|
||||
{ title: 'Início', href: '/campanha', icon: HomeIcon },
|
||||
{ title: 'Municípios', href: '/campanha/municipios', icon: MapPinIcon },
|
||||
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
|
||||
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
|
||||
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
|
||||
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
|
||||
|
|
@ -74,7 +76,12 @@ export const getCampaignBottomNav = (role: CampaignUser['role']): CampaignNavIte
|
|||
if (role === 'leader') return nav
|
||||
|
||||
return nav
|
||||
.filter((item) => item.href !== '/campanha/apoiadores' && item.href !== '/campanha/assessores')
|
||||
.filter(
|
||||
(item) =>
|
||||
item.href !== '/campanha/territorios' &&
|
||||
item.href !== '/campanha/apoiadores' &&
|
||||
item.href !== '/campanha/assessores',
|
||||
)
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
|
|
|
|||
66
src/utilities/territoryListFilters.ts
Normal file
66
src/utilities/territoryListFilters.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
parseTerritoryListParams,
|
||||
territoryListStateToRawParams,
|
||||
type TerritoryCoverage,
|
||||
type TerritoryListState,
|
||||
} from '@/utilities/territoryListUrl'
|
||||
|
||||
export type TerritoryFilterParam = 'region' | 'coverage'
|
||||
|
||||
export type TerritoryFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const territoryCoverageLabels = {
|
||||
com_assessor: 'Cobertura completa',
|
||||
sem_assessor: 'Com lacunas de assessoria',
|
||||
} as const satisfies Record<TerritoryCoverage, string>
|
||||
|
||||
export const territoryCoverageOptions = (
|
||||
['com_assessor', 'sem_assessor'] as const satisfies readonly TerritoryCoverage[]
|
||||
).map((value) => ({ value, label: territoryCoverageLabels[value] }))
|
||||
|
||||
export const toggleTerritoryRegionFilter = (
|
||||
state: TerritoryListState,
|
||||
region: string,
|
||||
): TerritoryListState => {
|
||||
const current = state.regions ?? []
|
||||
const regions = current.some((entry) => entry === region)
|
||||
? current.filter((entry) => entry !== region)
|
||||
: [...current, region]
|
||||
return parseTerritoryListParams({
|
||||
...territoryListStateToRawParams(state),
|
||||
region: regions,
|
||||
})
|
||||
}
|
||||
|
||||
export const toggleTerritoryCoverageFilter = (
|
||||
state: TerritoryListState,
|
||||
coverage: TerritoryCoverage,
|
||||
): TerritoryListState =>
|
||||
parseTerritoryListParams({
|
||||
...territoryListStateToRawParams(state),
|
||||
coverage: state.coverage === coverage ? undefined : coverage,
|
||||
})
|
||||
|
||||
export const clearTerritoryListFilters = (state: TerritoryListState): TerritoryListState => ({
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const isTerritoryFilterActive = (
|
||||
state: TerritoryListState,
|
||||
param: TerritoryFilterParam,
|
||||
): boolean => (param === 'region' ? Boolean(state.regions?.length) : Boolean(state.coverage))
|
||||
|
||||
const firstTerritoryNamesLabel = (names: readonly string[]): string =>
|
||||
names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`
|
||||
|
||||
export const formatTerritoryActiveFiltersSummary = (state: TerritoryListState): string | null => {
|
||||
const parts: string[] = []
|
||||
if (state.regions?.length) parts.push(firstTerritoryNamesLabel(state.regions))
|
||||
if (state.coverage) parts.push(territoryCoverageLabels[state.coverage])
|
||||
if (state.q) parts.push(`Busca "${state.q}"`)
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
191
src/utilities/territoryListUrl.ts
Normal file
191
src/utilities/territoryListUrl.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { bahiaIdentityTerritories, type BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import {
|
||||
allParamValues,
|
||||
firstValue,
|
||||
inspectRawListParams,
|
||||
normalizedText,
|
||||
type RawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import type { TerritorySortDir, TerritorySortKey } from '@/utilities/territoryOverview'
|
||||
|
||||
export type TerritoryListSortKey = TerritorySortKey
|
||||
export type TerritoryListSortDirection = TerritorySortDir
|
||||
export type TerritoryCoverage = 'com_assessor' | 'sem_assessor'
|
||||
|
||||
export type TerritoryListState = {
|
||||
q?: string
|
||||
regions?: BahiaIdentityTerritory[]
|
||||
coverage?: TerritoryCoverage
|
||||
sort?: TerritoryListSortKey
|
||||
dir?: TerritoryListSortDirection
|
||||
}
|
||||
|
||||
export type TerritoryListSearchParams = RawSearchParams
|
||||
|
||||
const territoryListParamNames = ['q', 'region', 'coverage', 'sort', 'dir'] as const
|
||||
const territoryListParamNameSet = new Set<string>(territoryListParamNames)
|
||||
const territoryListSortKeys: TerritoryListSortKey[] = [
|
||||
'region',
|
||||
'municipalities',
|
||||
'votes2022',
|
||||
'pct',
|
||||
'validVotes2022',
|
||||
'estimate2026',
|
||||
'coverage',
|
||||
]
|
||||
const territoryListSortKeySet = new Set<string>(territoryListSortKeys)
|
||||
const territoryListSortDirSet = new Set<TerritoryListSortDirection>(['asc', 'desc'])
|
||||
const DEFAULT_TERRITORY_LIST_SORT_KEY: TerritoryListSortKey = 'pct'
|
||||
const DEFAULT_TERRITORY_LIST_SORT_DIR: TerritoryListSortDirection = 'desc'
|
||||
|
||||
export const territoryListSortLabels: Record<TerritoryListSortKey, string> = {
|
||||
region: 'Território',
|
||||
municipalities: 'Municípios',
|
||||
votes2022: 'Votos 2022',
|
||||
pct: '% da própria votação',
|
||||
validVotes2022: 'Válidos 2022',
|
||||
estimate2026: 'Estimativa 2026 (média)',
|
||||
coverage: 'Assessoria',
|
||||
}
|
||||
|
||||
const canonicalTerritoryBySearchValue = new Map(
|
||||
bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]),
|
||||
)
|
||||
|
||||
const parseRegions = (value: string | string[] | undefined): BahiaIdentityTerritory[] => {
|
||||
const regions: BahiaIdentityTerritory[] = []
|
||||
const seen = new Set<BahiaIdentityTerritory>()
|
||||
for (const token of allParamValues(value)) {
|
||||
const region = canonicalTerritoryBySearchValue.get(normalizeSearchPhrase(token))
|
||||
if (!region || seen.has(region)) continue
|
||||
seen.add(region)
|
||||
regions.push(region)
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
export const defaultTerritoryListSortDir = (
|
||||
key: TerritoryListSortKey,
|
||||
): TerritoryListSortDirection => (key === 'region' ? 'asc' : 'desc')
|
||||
|
||||
export const resolveTerritoryListSort = (
|
||||
state: TerritoryListState,
|
||||
): { sort: TerritoryListSortKey; dir: TerritoryListSortDirection } => {
|
||||
const sort = state.sort ?? DEFAULT_TERRITORY_LIST_SORT_KEY
|
||||
return { sort, dir: state.dir ?? defaultTerritoryListSortDir(sort) }
|
||||
}
|
||||
|
||||
export const formatTerritoryListSortSummary = (
|
||||
sort: TerritoryListSortKey,
|
||||
dir: TerritoryListSortDirection,
|
||||
): string => {
|
||||
if (sort === 'region') {
|
||||
return dir === 'asc' ? 'Ordenado por território (A–Z)' : 'Ordenado por território (Z–A)'
|
||||
}
|
||||
return `Ordenado por ${territoryListSortLabels[sort]} (${dir === 'asc' ? 'menor primeiro' : 'maior primeiro'})`
|
||||
}
|
||||
|
||||
export const parseTerritoryListParams = (params: TerritoryListSearchParams): TerritoryListState => {
|
||||
const q = normalizedText(firstValue(params.q))
|
||||
const regions = parseRegions(params.region)
|
||||
const rawCoverage = firstValue(params.coverage)
|
||||
const rawSort = firstValue(params.sort)
|
||||
const rawDir = firstValue(params.dir)
|
||||
|
||||
return {
|
||||
...(q ? { q } : {}),
|
||||
...(regions.length ? { regions } : {}),
|
||||
...(rawCoverage === 'com_assessor' || rawCoverage === 'sem_assessor'
|
||||
? { coverage: rawCoverage }
|
||||
: {}),
|
||||
...(rawSort && territoryListSortKeySet.has(rawSort)
|
||||
? { sort: rawSort as TerritoryListSortKey }
|
||||
: {}),
|
||||
...(rawDir && territoryListSortDirSet.has(rawDir as TerritoryListSortDirection)
|
||||
? { dir: rawDir as TerritoryListSortDirection }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const territoryListStateToRawParams = (
|
||||
state: TerritoryListState,
|
||||
): TerritoryListSearchParams => ({
|
||||
q: state.q,
|
||||
region: state.regions,
|
||||
coverage: state.coverage,
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const serializeTerritoryListSearchParams = (state: TerritoryListState): URLSearchParams => {
|
||||
const canonical = parseTerritoryListParams(territoryListStateToRawParams(state))
|
||||
const { sort, dir } = resolveTerritoryListSort(canonical)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (canonical.q) params.set('q', canonical.q)
|
||||
for (const region of canonical.regions ?? []) params.append('region', region)
|
||||
if (canonical.coverage) params.set('coverage', canonical.coverage)
|
||||
if (sort !== DEFAULT_TERRITORY_LIST_SORT_KEY || dir !== DEFAULT_TERRITORY_LIST_SORT_DIR) {
|
||||
params.set('sort', sort)
|
||||
if (dir !== defaultTerritoryListSortDir(sort)) params.set('dir', dir)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
export const buildTerritoryListHref = (state: TerritoryListState): string => {
|
||||
const query = serializeTerritoryListSearchParams(state).toString()
|
||||
return query ? `/campanha/territorios?${query}` : '/campanha/territorios'
|
||||
}
|
||||
|
||||
export const buildTerritorySortHref = (
|
||||
state: TerritoryListState,
|
||||
nextKey: TerritoryListSortKey,
|
||||
): string => {
|
||||
const current = resolveTerritoryListSort(state)
|
||||
const dir =
|
||||
current.sort === nextKey
|
||||
? current.dir === 'asc'
|
||||
? 'desc'
|
||||
: 'asc'
|
||||
: defaultTerritoryListSortDir(nextKey)
|
||||
return buildTerritoryListHref({ ...state, sort: nextKey, dir })
|
||||
}
|
||||
|
||||
export const resolveTerritoryListUrl = (
|
||||
params: TerritoryListSearchParams,
|
||||
): { state: TerritoryListState; href: string; redirectHref?: string } => {
|
||||
const state = parseTerritoryListParams(params)
|
||||
const href = buildTerritoryListHref(state)
|
||||
const canonicalQuery = serializeTerritoryListSearchParams(state).toString()
|
||||
const raw = inspectRawListParams(params, territoryListParamNameSet)
|
||||
return {
|
||||
state,
|
||||
href,
|
||||
...(raw.hasUnsupportedParams || raw.query !== canonicalQuery ? { redirectHref: href } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const sortOptionLabel = (key: TerritoryListSortKey, dir: TerritoryListSortDirection): string => {
|
||||
const label = territoryListSortLabels[key]
|
||||
if (key === 'region') return `${label} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
|
||||
return `${label} (${dir === 'asc' ? 'menor → maior' : 'maior → menor'})`
|
||||
}
|
||||
|
||||
export const territoryListSortOptions = territoryListSortKeys.flatMap((key) =>
|
||||
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
|
||||
)
|
||||
|
||||
export const serializeTerritorySortValue = (
|
||||
key: TerritoryListSortKey,
|
||||
dir: TerritoryListSortDirection,
|
||||
): string => `${key}|${dir}`
|
||||
|
||||
export const parseTerritorySortValue = (
|
||||
value: string,
|
||||
): { key: TerritoryListSortKey; dir: TerritoryListSortDirection } | null => {
|
||||
const [key, dir] = value.split('|')
|
||||
if (!territoryListSortKeySet.has(key)) return null
|
||||
if (!territoryListSortDirSet.has(dir as TerritoryListSortDirection)) return null
|
||||
return { key: key as TerritoryListSortKey, dir: dir as TerritoryListSortDirection }
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@
|
|||
* fixture. The server loader lives in `loadTerritoryOverview.ts`.
|
||||
*/
|
||||
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
const SALVADOR_CITY = 'Salvador'
|
||||
const SALVADOR_SUB_LABEL = 'Salvador (19 zonas)'
|
||||
const RMS_DEMAIS_SUB_LABEL = 'Demais municípios da RMS'
|
||||
|
|
@ -50,6 +52,10 @@ export type TerritoryOverviewRow = {
|
|||
subRows?: TerritoryOverviewSubRow[]
|
||||
}
|
||||
|
||||
export type TerritoryTableRow =
|
||||
| ({ variant: 'parent' } & Omit<TerritoryOverviewRow, 'subRows'>)
|
||||
| ({ variant: 'sub'; parentRegion: string } & TerritoryOverviewSubRow)
|
||||
|
||||
export type TerritorySortKey =
|
||||
| 'region'
|
||||
| 'municipalities'
|
||||
|
|
@ -204,3 +210,43 @@ export const sortTerritoryRows = (
|
|||
return (a - b) * factor
|
||||
})
|
||||
}
|
||||
|
||||
type TerritoryRowFilters = {
|
||||
q?: string
|
||||
regions?: readonly string[]
|
||||
coverage?: 'com_assessor' | 'sem_assessor'
|
||||
}
|
||||
|
||||
/** Filters top-level territories; display-only sub-rows always follow their parent. */
|
||||
export const filterTerritoryRows = (
|
||||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||||
filters: TerritoryRowFilters,
|
||||
): TerritoryOverviewRow[] => {
|
||||
const query = filters.q ? normalizeSearchPhrase(filters.q) : ''
|
||||
const selectedRegions = new Set(filters.regions ?? [])
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (query && !normalizeSearchPhrase(row.region).includes(query)) return false
|
||||
if (selectedRegions.size && !selectedRegions.has(row.region)) return false
|
||||
if (filters.coverage === 'com_assessor') {
|
||||
return row.municipalityCount > 0 && row.withAdvisorCount === row.municipalityCount
|
||||
}
|
||||
if (filters.coverage === 'sem_assessor') {
|
||||
return row.withAdvisorCount < row.municipalityCount
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Flattens display-only sub-rows while preserving their parent adjacency. */
|
||||
export const flattenTerritoryRows = (
|
||||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||||
): TerritoryTableRow[] =>
|
||||
rows.flatMap(({ subRows, ...row }) => [
|
||||
{ ...row, variant: 'parent' as const },
|
||||
...(subRows ?? []).map((subRow) => ({
|
||||
...subRow,
|
||||
variant: 'sub' as const,
|
||||
parentRegion: row.region,
|
||||
})),
|
||||
])
|
||||
|
|
|
|||
63
tests/e2e/campaignTerritories.e2e.spec.ts
Normal file
63
tests/e2e/campaignTerritories.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
test.describe('Territórios de Identidade', () => {
|
||||
test('staff sorts, filters and opens the municipality queue for a territory', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const email = `${fixtures.value('territorios-coordinator')}@example.com`
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Coordenadora Territórios'),
|
||||
email,
|
||||
password,
|
||||
role: 'coordinator',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
await page.getByRole('link', { name: 'Territórios', exact: true }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/territorios$/)
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Territórios de Identidade' }),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)
|
||||
|
||||
await page.getByRole('button', { name: 'Filtrar por Território' }).click()
|
||||
await page.locator('a[href*="/campanha/territorios?region=Irec"]').click()
|
||||
await expect(page).toHaveURL(/region=Irec%C3%AA/)
|
||||
await expect(page.getByText('1 território encontrado')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Irecê', exact: true }).first().click()
|
||||
await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/)
|
||||
})
|
||||
|
||||
test('leader cannot open the territories page', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const password = fixtures.value('senha')
|
||||
const phone = fixtures.phone()
|
||||
await campaign.payload.create({
|
||||
collection: 'campaignUser',
|
||||
data: {
|
||||
name: fixtures.value('Liderança Territórios'),
|
||||
username: phone,
|
||||
password,
|
||||
role: 'leader',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, phone, password)
|
||||
await expect(page.getByRole('link', { name: 'Territórios', exact: true })).toHaveCount(0)
|
||||
|
||||
await page.goto('/campanha/territorios')
|
||||
await expect(page).toHaveURL(/\/campanha\/?$/)
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
for (const path of [
|
||||
'/campanha/login',
|
||||
'/campanha/municipios',
|
||||
'/campanha/territorios',
|
||||
'/campanha/municipios/e2e-prewarm',
|
||||
'/campanha/demandas',
|
||||
'/campanha/conceitos',
|
||||
|
|
|
|||
110
tests/unit/territoryListUrl.unit.spec.ts
Normal file
110
tests/unit/territoryListUrl.unit.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearTerritoryListFilters,
|
||||
formatTerritoryActiveFiltersSummary,
|
||||
toggleTerritoryCoverageFilter,
|
||||
toggleTerritoryRegionFilter,
|
||||
} from '@/utilities/territoryListFilters'
|
||||
import {
|
||||
buildTerritoryListHref,
|
||||
buildTerritorySortHref,
|
||||
parseTerritoryListParams,
|
||||
resolveTerritoryListSort,
|
||||
resolveTerritoryListUrl,
|
||||
serializeTerritoryListSearchParams,
|
||||
} from '@/utilities/territoryListUrl'
|
||||
|
||||
describe('territory list URL contract', () => {
|
||||
it('parses and canonicalizes supported filters', () => {
|
||||
expect(
|
||||
parseTerritoryListParams({
|
||||
q: ' irecê ',
|
||||
region: ['Velho Chico', 'Irecê', 'Velho Chico', 'desconhecido'],
|
||||
coverage: 'sem_assessor',
|
||||
sort: 'votes2022',
|
||||
dir: 'asc',
|
||||
}),
|
||||
).toEqual({
|
||||
q: 'irecê',
|
||||
regions: ['Velho Chico', 'Irecê'],
|
||||
coverage: 'sem_assessor',
|
||||
sort: 'votes2022',
|
||||
dir: 'asc',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the default sort pair from the canonical query', () => {
|
||||
const state = parseTerritoryListParams({ sort: 'pct', dir: 'desc' })
|
||||
|
||||
expect(serializeTerritoryListSearchParams(state).toString()).toBe('')
|
||||
expect(resolveTerritoryListSort(state)).toEqual({ sort: 'pct', dir: 'desc' })
|
||||
})
|
||||
|
||||
it('preserves filters while toggling sort direction', () => {
|
||||
const state = parseTerritoryListParams({ q: 'chico', region: 'Velho Chico' })
|
||||
|
||||
expect(buildTerritorySortHref(state, 'votes2022')).toBe(
|
||||
'/campanha/territorios?q=chico®ion=Velho+Chico&sort=votes2022',
|
||||
)
|
||||
expect(
|
||||
buildTerritorySortHref(
|
||||
parseTerritoryListParams({ ...state, sort: 'votes2022', dir: 'desc' }),
|
||||
'votes2022',
|
||||
),
|
||||
).toContain('dir=asc')
|
||||
})
|
||||
|
||||
it('redirects unsupported and non-canonical query strings', () => {
|
||||
expect(resolveTerritoryListUrl({ q: ' Irecê ', unknown: 'x' })).toEqual({
|
||||
state: { q: 'Irecê' },
|
||||
href: '/campanha/territorios?q=Irec%C3%AA',
|
||||
redirectHref: '/campanha/territorios?q=Irec%C3%AA',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears invalid values instead of serializing them', () => {
|
||||
const state = parseTerritoryListParams({
|
||||
coverage: 'talvez',
|
||||
sort: 'unknown',
|
||||
dir: 'sideways',
|
||||
})
|
||||
|
||||
expect(state).toEqual({})
|
||||
expect(buildTerritoryListHref(state)).toBe('/campanha/territorios')
|
||||
})
|
||||
})
|
||||
|
||||
describe('territory list filter state', () => {
|
||||
it('toggles region and coverage filters canonically', () => {
|
||||
const withRegion = toggleTerritoryRegionFilter({}, 'Irecê')
|
||||
expect(withRegion).toEqual({ regions: ['Irecê'] })
|
||||
expect(toggleTerritoryRegionFilter(withRegion, 'Irecê')).toEqual({})
|
||||
|
||||
const withCoverage = toggleTerritoryCoverageFilter({}, 'sem_assessor')
|
||||
expect(withCoverage).toEqual({ coverage: 'sem_assessor' })
|
||||
expect(toggleTerritoryCoverageFilter(withCoverage, 'sem_assessor')).toEqual({})
|
||||
})
|
||||
|
||||
it('clears filters while preserving the selected sort', () => {
|
||||
expect(
|
||||
clearTerritoryListFilters({
|
||||
q: 'irecê',
|
||||
regions: ['Irecê'],
|
||||
coverage: 'sem_assessor',
|
||||
sort: 'votes2022',
|
||||
dir: 'asc',
|
||||
}),
|
||||
).toEqual({ sort: 'votes2022', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('formats a compact active-filter summary', () => {
|
||||
expect(
|
||||
formatTerritoryActiveFiltersSummary({
|
||||
q: 'chico',
|
||||
regions: ['Irecê', 'Velho Chico'],
|
||||
coverage: 'sem_assessor',
|
||||
}),
|
||||
).toBe('Irecê, Velho Chico · Com lacunas de assessoria · Busca "chico"')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
computeTerritoryRollup,
|
||||
filterTerritoryRows,
|
||||
flattenTerritoryRows,
|
||||
sortTerritoryRows,
|
||||
type TerritoryMunicipalityInput,
|
||||
} from '@/utilities/territoryOverview'
|
||||
|
|
@ -144,3 +146,51 @@ describe('sortTerritoryRows', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTerritoryRows', () => {
|
||||
it('places Metropolitano sub-rows immediately after their parent', () => {
|
||||
const rows = sortTerritoryRows(computeTerritoryRollup(fixture), 'region', 'asc')
|
||||
const flattened = flattenTerritoryRows(rows)
|
||||
const metroIndex = flattened.findIndex(
|
||||
(row) => row.variant === 'parent' && row.region === 'Metropolitano de Salvador',
|
||||
)
|
||||
|
||||
expect(flattened.slice(metroIndex, metroIndex + 3).map((row) => row.variant)).toEqual([
|
||||
'parent',
|
||||
'sub',
|
||||
'sub',
|
||||
])
|
||||
expect(flattened[metroIndex + 1]).toMatchObject({
|
||||
variant: 'sub',
|
||||
parentRegion: 'Metropolitano de Salvador',
|
||||
label: 'Salvador (19 zonas)',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterTerritoryRows', () => {
|
||||
const rows = computeTerritoryRollup(fixture)
|
||||
|
||||
it('combines accent-insensitive search and selected regions', () => {
|
||||
expect(
|
||||
filterTerritoryRows(rows, {
|
||||
q: 'irece',
|
||||
regions: ['Irecê', 'Velho Chico'],
|
||||
}).map((row) => row.region),
|
||||
).toEqual(['Irecê'])
|
||||
})
|
||||
|
||||
it('treats com assessor as complete coverage and sem assessor as any gap', () => {
|
||||
expect(
|
||||
filterTerritoryRows(rows, { coverage: 'com_assessor' }).map((row) => row.region),
|
||||
).toEqual(['Velho Chico'])
|
||||
expect(
|
||||
filterTerritoryRows(rows, { coverage: 'sem_assessor' }).map((row) => row.region),
|
||||
).toEqual(['Irecê', 'Metropolitano de Salvador'])
|
||||
})
|
||||
|
||||
it('keeps Metropolitano sub-rows attached when the parent matches', () => {
|
||||
const [metropolitano] = filterTerritoryRows(rows, { q: 'metropolitano' })
|
||||
expect(metropolitano.subRows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue