From a9e1900290dc710e042fa1ce8d6505baf899cfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 21:50:24 -0300 Subject: [PATCH] =?UTF-8?q?B21:=20cria=20p=C3=A1gina=20pr=C3=B3pria=20dos?= =?UTF-8?q?=20territ=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + ...paign-campanha-app-territorios-page-tsx.md | 61 ++++ AGENTS.md | 4 +- .../ordenacao-filtros-lista-dobradinhas.md | 6 +- .../ordenacao-filtros-lista-liderancas.md | 4 +- docs/plans/pagina-territorios-identidade.md | 16 +- docs/roadmap.md | 3 +- src/app/(campaign)/campanha/(app)/page.tsx | 32 -- .../campanha/(app)/territorios/page.tsx | 68 +++++ .../campaign/dashboard/CampaignDashboard.tsx | 5 - .../municipality/MunicipalityFilters.tsx | 45 +-- .../municipality/MunicipalityHeaderFilter.tsx | 278 +++++------------- .../municipality/MunicipalitySortableHead.tsx | 104 ++----- .../municipality/TerritoryFilters.tsx | 193 ++++++++++++ .../municipality/TerritoryHeaderFilter.tsx | 85 ++++++ .../campaign/municipality/TerritoryList.tsx | 84 ++++++ .../municipality/TerritoryListColumns.tsx | 149 ++++++++++ .../municipality/TerritoryOverviewTable.tsx | 211 ------------- .../municipality/TerritorySortableHead.tsx | 71 +++++ .../shared/CampaignHeaderFilterPopover.tsx | 173 +++++++++++ .../shared/CampaignMobileMultiFilterField.tsx | 46 +++ .../campaign/shared/CampaignSortableHead.tsx | 102 +++++++ src/components/campaign/shell/nav.ts | 9 +- src/utilities/territoryListFilters.ts | 66 +++++ src/utilities/territoryListUrl.ts | 191 ++++++++++++ src/utilities/territoryOverview.ts | 46 +++ tests/e2e/campaignTerritories.e2e.spec.ts | 63 ++++ tests/e2e/setup.e2e.spec.ts | 1 + tests/unit/territoryListUrl.unit.spec.ts | 110 +++++++ tests/unit/territoryOverview.unit.spec.ts | 50 ++++ 30 files changed, 1698 insertions(+), 579 deletions(-) create mode 100644 .impeccable/critique/2026-07-25T23-12-35Z__src-app-campaign-campanha-app-territorios-page-tsx.md create mode 100644 src/app/(campaign)/campanha/(app)/territorios/page.tsx create mode 100644 src/components/campaign/municipality/TerritoryFilters.tsx create mode 100644 src/components/campaign/municipality/TerritoryHeaderFilter.tsx create mode 100644 src/components/campaign/municipality/TerritoryList.tsx create mode 100644 src/components/campaign/municipality/TerritoryListColumns.tsx delete mode 100644 src/components/campaign/municipality/TerritoryOverviewTable.tsx create mode 100644 src/components/campaign/municipality/TerritorySortableHead.tsx create mode 100644 src/components/campaign/shared/CampaignHeaderFilterPopover.tsx create mode 100644 src/components/campaign/shared/CampaignMobileMultiFilterField.tsx create mode 100644 src/components/campaign/shared/CampaignSortableHead.tsx create mode 100644 src/utilities/territoryListFilters.ts create mode 100644 src/utilities/territoryListUrl.ts create mode 100644 tests/e2e/campaignTerritories.e2e.spec.ts create mode 100644 tests/unit/territoryListUrl.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index be9b2621..28fa21e8 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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=`. 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). diff --git a/.impeccable/critique/2026-07-25T23-12-35Z__src-app-campaign-campanha-app-territorios-page-tsx.md b/.impeccable/critique/2026-07-25T23-12-35Z__src-app-campaign-campanha-app-territorios-page-tsx.md new file mode 100644 index 00000000..7284eab8 --- /dev/null +++ b/.impeccable/critique/2026-07-25T23-12-35Z__src-app-campaign-campanha-app-territorios-page-tsx.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? diff --git a/AGENTS.md b/AGENTS.md index ebea4f58..c50b9d0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `@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=`. 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). diff --git a/docs/plans/ordenacao-filtros-lista-dobradinhas.md b/docs/plans/ordenacao-filtros-lista-dobradinhas.md index 23163e37..368cddd4 100644 --- a/docs/plans/ordenacao-filtros-lista-dobradinhas.md +++ b/docs/plans/ordenacao-filtros-lista-dobradinhas.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/`. diff --git a/docs/plans/ordenacao-filtros-lista-liderancas.md b/docs/plans/ordenacao-filtros-lista-liderancas.md index 72864754..551fc045 100644 --- a/docs/plans/ordenacao-filtros-lista-liderancas.md +++ b/docs/plans/ordenacao-filtros-lista-liderancas.md @@ -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`). diff --git a/docs/plans/pagina-territorios-identidade.md b/docs/plans/pagina-territorios-identidade.md index 89418f59..9be7cbc9 100644 --- a/docs/plans/pagina-territorios-identidade.md +++ b/docs/plans/pagina-territorios-identidade.md @@ -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=` (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". diff --git a/docs/roadmap.md b/docs/roadmap.md index 530800ee..b07ab2c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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=`; 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) diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index dea422f9..b5fabac0 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -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 ) : null } - territorySlot={ - isStaff ? ( - - ) : null - } /> ) } @@ -105,18 +88,3 @@ const DashboardMapSection = async ({ ) } - -const TerritoryOverviewSection = async ({ payload }: { payload: Payload }) => { - const rows = await loadTerritoryOverview(payload) - return ( -
-

- Territórios de Identidade -

- -
- ) -} diff --git a/src/app/(campaign)/campanha/(app)/territorios/page.tsx b/src/app/(campaign)/campanha/(app)/territorios/page.tsx new file mode 100644 index 00000000..4ebeb18f --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/territorios/page.tsx @@ -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> +} + +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 ( + +
+

+ Territórios de Identidade +

+

+ 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. +

+
+ + + + + resolvedUrl.href} + /> + + +
+ ) +} diff --git a/src/components/campaign/dashboard/CampaignDashboard.tsx b/src/components/campaign/dashboard/CampaignDashboard.tsx index 49884916..dc199cbf 100644 --- a/src/components/campaign/dashboard/CampaignDashboard.tsx +++ b/src/components/campaign/dashboard/CampaignDashboard.tsx @@ -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 }) => (
@@ -85,8 +82,6 @@ export const CampaignDashboard = ({ /> - {territorySlot} -
void -}) => ( - - {label} - { - if (event.target.value) onToggle(event.target.value) - }} - className="min-h-11 w-full" - > - - {selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel} - - {options.map((option) => ( - - {selected.includes(option.value) ? `✓ ${option.label}` : option.label} - - ))} - - -) - type MunicipalityFiltersProps = { state: MunicipalityListState showStaffFilters: boolean @@ -237,7 +200,7 @@ export const MunicipalityFilters = ({ ) : null} {regionFilterOptions.length ? ( - ) : null} {showStaffFilters && advisorFilterOptions.length ? ( - -const FilterOptionLink = ({ - href, - selected, - checkbox = false, - onChoose, - children, -}: { - href: string - selected: boolean - checkbox?: boolean - onChoose: () => void - children: ReactNode -}) => ( - - {checkbox ? ( - <> - -) - export const MunicipalityHeaderFilter = ({ state, filterParam, options: 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 ( - { - setOpen(next) - if (!next) setQuery('') - }} - > - - - - -
-

- {definition.label} -

- {showClear ? ( - commit(clearedState)} - > - Limpar - - ) : null} -
- - {exclusiveRows.length ? ( -
- {exclusiveRows.map((row) => ( - commit(row.next)} - > - {row.label} - - ))} -
- ) : null} - - {searchable ? ( -
- setQuery(event.target.value)} - placeholder="Buscar…" - aria-label={`Buscar em ${definition.label}`} - className="min-h-11" - autoFocus - /> -
- ) : null} - - -
-
+ commit(clearedState), + } + : undefined + } + /> ) } diff --git a/src/components/campaign/municipality/MunicipalitySortableHead.tsx b/src/components/campaign/municipality/MunicipalitySortableHead.tsx index ae7db918..fe56596f 100644 --- a/src/components/campaign/municipality/MunicipalitySortableHead.tsx +++ b/src/components/campaign/municipality/MunicipalitySortableHead.tsx @@ -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 = ( - - {label} - {active ? ( - dir === 'asc' ? ( - - ) - - const labeledSort = tooltip ? ( - - {sortControl} - - ) : ( - sortControl - ) - return ( - - {filterParam ? ( -
- {labeledSort} + -
- ) : ( - labeledSort - )} -
+ ) : null + } + wrapSortControl={ + tooltip + ? (control) => ( + + {control} + + ) + : undefined + } + > + {label} + ) } diff --git a/src/components/campaign/municipality/TerritoryFilters.tsx b/src/components/campaign/municipality/TerritoryFilters.tsx new file mode 100644 index 00000000..e4985095 --- /dev/null +++ b/src/components/campaign/municipality/TerritoryFilters.tsx @@ -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 | 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 ( +
{ + event.preventDefault() + navigateTo({ ...state, q: search }) + }} + > +

+ {isPending ? 'Atualizando resultados…' : ''} +

+ +
+ { + setSearch(event.target.value) + scheduleSearch(event.target.value) + }} + /> + {activeSummary ? ( +

+ {activeSummary} +

+ ) : null} + {activeSummary ? ( + + ) : null} +
+ +
+ {activeSummary ?

{activeSummary}

: null} + navigateTo(toggleTerritoryRegionFilter(state, value))} + /> + + + Assessoria + + navigateTo({ + ...state, + coverage: + event.target.value === 'com_assessor' || event.target.value === 'sem_assessor' + ? event.target.value + : undefined, + }) + } + className="w-full [&_select]:h-11" + > + Todas + {Object.entries(territoryCoverageLabels).map(([value, label]) => ( + + {label} + + ))} + + + + + Ordenar + { + 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) => ( + + {option.label} + + ))} + + +
+
+ ) +} diff --git a/src/components/campaign/municipality/TerritoryHeaderFilter.tsx b/src/components/campaign/municipality/TerritoryHeaderFilter.tsx new file mode 100644 index 00000000..dcb6aff2 --- /dev/null +++ b/src/components/campaign/municipality/TerritoryHeaderFilter.tsx @@ -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 ( + setOptimisticState(clearedState), + } + : undefined + } + /> + ) +} diff --git a/src/components/campaign/municipality/TerritoryList.tsx b/src/components/campaign/municipality/TerritoryList.tsx new file mode 100644 index 00000000..a561d8a6 --- /dev/null +++ b/src/components/campaign/municipality/TerritoryList.tsx @@ -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 }) => ( + + + + + Nenhum território encontrado + + Ajuste a busca ou os filtros para comparar os territórios. + + + + + Limpar busca e filtros + + + +) + +export const TerritoryList = ({ + rows, + state, + regionOptions, +}: { + rows: TerritoryOverviewRow[] + state: TerritoryListState + regionOptions: TerritoryFilterOption[] +}) => { + const { sort, dir } = resolveTerritoryListSort(state) + const sortSummary = formatTerritoryListSortSummary(sort, dir) + + return ( + <> +

+ {sortSummary} +

+ + row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}` + } + rowClassName={(row) => (row.variant === 'sub' ? 'bg-muted/30' : undefined)} + empty={} + /> + + ) +} diff --git a/src/components/campaign/municipality/TerritoryListColumns.tsx b/src/components/campaign/municipality/TerritoryListColumns.tsx new file mode 100644 index 00000000..c750af14 --- /dev/null +++ b/src/components/campaign/municipality/TerritoryListColumns.tsx @@ -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 }) => { + const prior = [ + { year: '2014', value: votesByYear['2014'] ?? 0 }, + { year: '2018', value: votesByYear['2018'] ?? 0 }, + ] + + return ( +
+ + {formatElectionNumber(votesByYear['2022'] ?? 0)} + + + {prior.map(({ year, value }) => `${year}: ${formatElectionNumber(value)}`).join(' · ')} + +
+ ) +} + +const CoverageCell = ({ + withAdvisorCount, + total, + muted, +}: { + withAdvisorCount: number + total: number + muted: boolean +}) => { + if (total === 0) return — + if (withAdvisorCount === 0) { + return 0 de {total} + } + return ( + + {withAdvisorCount} de {total} + + ) +} + +export const territoryListColumns = ({ + state, + regionOptions, +}: { + state: TerritoryListState + regionOptions: TerritoryFilterOption[] +}): Array> => [ + { + id: 'region', + mandatory: true, + defaultVisible: true, + head: ( + + ), + cell: (row) => + row.variant === 'parent' ? ( + + {row.region} + + ) : ( + {row.label} + ), + cellClassName: (row) => + `sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`, + }, + { + id: 'municipalities', + defaultVisible: true, + head: , + cell: (row) => row.municipalityCount, + cellClassName: 'text-right tabular-nums', + }, + { + id: 'votes2022', + defaultVisible: true, + head: , + cell: (row) => , + cellClassName: 'text-right tabular-nums', + }, + { + id: 'pct', + defaultVisible: true, + head: , + cell: (row) => formatPercent(row.pctPropriaVotacao), + cellClassName: (row) => + `text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`, + }, + { + id: 'validVotes2022', + defaultVisible: true, + head: , + cell: (row) => formatElectionNumber(row.validVotes2022), + cellClassName: 'text-right tabular-nums text-muted-foreground', + }, + { + id: 'estimate2026', + defaultVisible: true, + head: , + cell: (row) => formatElectionNumber(row.estimate2026), + cellClassName: (row) => + `text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`, + }, + { + id: 'coverage', + defaultVisible: true, + head: ( + + ), + cell: (row) => ( + + ), + cellClassName: 'text-right', + }, +] diff --git a/src/components/campaign/municipality/TerritoryOverviewTable.tsx b/src/components/campaign/municipality/TerritoryOverviewTable.tsx deleted file mode 100644 index 595900b4..00000000 --- a/src/components/campaign/municipality/TerritoryOverviewTable.tsx +++ /dev/null @@ -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('pct') - const [sortDir, setSortDir] = useState('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