From b6e5d8be57741b95b3537799e23e881c0a20c84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Fri, 24 Jul 2026 17:17:31 -0300 Subject: [PATCH] Add comparative Identity Territory table to campaign dashboard (E17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First regional surface in /campanha: a sortable 27-TI overview table on the Início staff dashboard, next to the municipal map. Per-TI aggregates only (sums and ratios of aggregates — MAUP safeguard); Metropolitano decomposed into Salvador (19 zones) vs. demais RMS. Loader is server-only with overrideAccess:true (regional context, not management; TI-level exposure only); pure rollup stays client-safe for the sortable table. First slice of E12, which extends with capture/LQ/meta coverage. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/tabela-ti-inicio.md | 8 +- docs/roadmap.md | 11 +- src/app/(campaign)/campanha/(app)/page.tsx | 32 +++ src/components/campaign/CampaignDashboard.tsx | 5 + .../campaign/TerritoryOverviewTable.tsx | 203 +++++++++++++++++ src/utilities/loadTerritoryOverview.ts | 88 ++++++++ src/utilities/territoryOverview.ts | 206 ++++++++++++++++++ tests/unit/territoryOverview.unit.spec.ts | 102 +++++++++ 9 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 src/components/campaign/TerritoryOverviewTable.tsx create mode 100644 src/utilities/loadTerritoryOverview.ts create mode 100644 src/utilities/territoryOverview.ts create mode 100644 tests/unit/territoryOverview.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a3637ddb..a9ba46ea 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **E17 entregue em código (2026-07-24):** tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`) — `src/utilities/territoryOverview.ts` (rollup puro, client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `src/utilities/loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true` — leitura regional completa para advisor é contexto, não gestão; exposição só agregados TI-level) + `src/components/campaign/TerritoryOverviewTable.tsx` (tabela shadcn densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Unit `territoryOverview.unit.spec.ts` (10 testes); tsc/lint/knip/unit 266/int 345/build verde; Aikido 0 findings. **Débito:** A11 ausente → share calculado localmente no rollup; A11 unifica quando entrar. Plano: [`tabela-ti-inicio.md`](../../../docs/plans/tabela-ti-inicio.md). Primeira fatia de E12 (estende com captura/LQ/cobertura de meta por TI). - **B15 registrado (2026-07-24):** ordenar `/campanha/municipios` pelo header da coluna — contrato URL `?sort=`/`?dir=`, clique no header (desktop) + select mobile; A11 consome a key `votos`. Plano: [`ordenacao-colunas-lista-municipios.md`](../../../docs/plans/ordenacao-colunas-lista-municipios.md). - **Fill-in registrado (2026-07-24):** seletor **Cenário** de `/campanha/municipios` → fileira de `MunicipalityFilters` (provider envolve filtros+resultados; overview sem seletor). Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md). - **E4R entregue em código + seed em produção (2026-07-24):** `pnpm db:seed:projecao` (`scripts/import-projecao.mjs` + `src/lib/projectionSheetParse.ts`) importa MAPA GERAL canônico → `expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` com **overwrite-always**; Salvador pulado; zero PII. No mesmo dia o grupo duplicado `voteGoals` foi removido do app (ver Decisões). Seed local e **produção Neon** (2026-07-24): arquivo canônico `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` (+ referência `Mapa_projecao_votos_Solla_2026.xlsx` só A×B), **189** municípios atualizados, **50** `priority=alta`, Salvador pulado, re-dry-run pós-apply **delta=0**. Plano: [`import-planilha-projecao.md`](../../../docs/plans/import-planilha-projecao.md). diff --git a/docs/plans/tabela-ti-inicio.md b/docs/plans/tabela-ti-inicio.md index 6d21d3d4..e0bba7df 100644 --- a/docs/plans/tabela-ti-inicio.md +++ b/docs/plans/tabela-ti-inicio.md @@ -1,12 +1,18 @@ # E17 — Tabela comparativa dos Territórios de Identidade no Início -Status: rascunho (pedido direto do candidato — overview regional ao lado do mapa do Início) +Status: entregue em código (2026-07-24) Atualizado em: 2026-07-24 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, E17; primeira fatia da camada TI — E12 estende) Impeccable: B — painel novo no dashboard staff existente (`/campanha`), sem rota nova Appetite: ~1 dia eng; sem migration, sem collection Responsável: — +### Revisão 2026-07-24 (auditoria → implementação) +- **A11 ausente confirmado:** `municipalityVoteRank.ts` não existe no repo → `% da própria votação` calculado localmente em `computeTerritoryRollup` (Σ TI ÷ Σ estadual 2022). A11 unifica o helper de share quando entrar. +- **Access:** loader `loadTerritoryOverview` usa `overrideAccess: true` (a `canReadMunicipality` do advisor escopa por `advisors contains user.id`) — exposição é agregado TI-level (somas/contagens), nunca PII por município; advisor vê a tabela completa (leitura regional é contexto). Confirmado contra o plano. +- **Split client/server:** o rollup puro ficou em `territoryOverview.ts` (client-safe, sem `server-only`) para a tabela client importar `sortTerritoryRows`/tipos; o loader server-only foi para `loadTerritoryOverview.ts`. +- **Entrega:** `territoryOverview.unit.spec.ts` (10 testes), tsc/lint/knip/unit 266/int 345/build verde, Aikido 0 findings. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (princípios 2 e 6) / `DESIGN.md` (register `product`; "Priority lift" para painéis de overview) · dashboard staff existente (`MunicipalityMapPanel`, `CampaignMetricStrip`). diff --git a/docs/roadmap.md b/docs/roadmap.md index 47b077af..2bdd9a61 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-24 (B15 ordenação por coluna na lista; fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; B14; janela 1: smoke, A11/E17, B15, R6, E8) +Atualizado em: 2026-07-24 (B15 ordenação por coluna na lista; fill-in Cenário junto aos filtros — [plano](plans/cenario-junto-filtros-municipios.md); E4R ✓; E17 ✓ tabela TI no Início; B14; janela 1: smoke, A11, B15, R6, E8) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -45,6 +45,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **Admin Payload (2026-07-23)** — export CSV de assinaturas e contatos ([plano](plans/exportar-csv-assinaturas.md)). - **Hardening de engenharia (2026-07-23, Fases 0–6)** — access lockdown (PII/CMS admin-only + gates de rota do leader), tooling gate (knip no CI, `--max-warnings=0`, ban `as never`), artefato TSE commitado + cache (`bahiaElectionAggregates`, `election-tse`, `loadMunicipalityScope`), pending honesto + Suspense streaming, state scoping, splits (`src/utilities/access/*`, `supporterImport.ts`, shells DRY). Tracker: [IMPROVE-CODE-QUALITY-PLAN.md](IMPROVE-CODE-QUALITY-PLAN.md) · ledger: [TECH-DEBT.md](TECH-DEBT.md) · mapa de testes: [TESTING.md](TESTING.md). - **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke. +- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md). ## Próximos — Campanha (`/campanha`) @@ -70,7 +71,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md)) - **A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação · a lente de prioridade atual da mesa (sessão 2026-07-23) · ~0,5–1d · Janela 1–2 · reusa contrato `sort`/`dir` de **B15**; E9 absorve como coluna/ordenação da fila · [plano](plans/ranking-votos-municipio.md) -- **E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md) +- ~~**E17** tabela comparativa dos Territórios de Identidade no Início (pedido do candidato) · somas/razões apenas, Metropolitano decomposto (salvaguardas MAUP) · ~1d · Janela 1–2 · primeira fatia de E12 · [plano](plans/tabela-ti-inicio.md)~~ — **entregue 2026-07-24** (`territoryOverview.ts` + `loadTerritoryOverview.ts` + `TerritoryOverviewTable.tsx`; rollup puro client-safe, loader `overrideAccess: true`, Metropolitano decomposto, ordenação client-side; sem migration; A11 ausente → share local) - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · camada de insight sobre o registro operacional `stateDeputy` (M4) · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) - **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) - **B8 F2** polígonos dos Municípios-zona de Salvador (ZE 1–19; Camaçari saiu do escopo na M1 — município inteiro): F1 catálogo zona→bairros entregue (hoje Salvador-only); falta prep do catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável · [plano](plans/poligonos-pracas-zona.md) @@ -118,7 +119,7 @@ flowchart TD B15n["B15 Ordenar lista
por coluna"] E4R["E4R ✓ Import planilha
(seed estratégia)"] A11n["A11 Posição em votos"] - E17n["E17 Tabela TI no Início"] + E17n["E17 ✓ Tabela TI no Início"] subgraph Intel["Inteligência de campanha"] E8i["E8 Conta da cadeira"] @@ -158,11 +159,11 @@ flowchart TD B8F2 -.ZE Salvador.-> B14n ``` -Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX de A11), **A11/E17** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9). +Paralelizáveis agora: **B15** (sort por coluna na lista; barato, desbloqueia UX de A11), **A11** (semana de onboarding; E4R ✓ seedou o quadro; A11 soft-dep B15), **E8** (A10 ✓ e remodelagem em produção), **C12** (paralelo a E8), **E16** (compõe o existente), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), fill-ins (Cenário junto aos filtros, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12) não absorver os deltas do ZAP (não compete com E8/E9). ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **B15** (sort por coluna) + **A11/E17** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E17 ✓** tabela TI no Início; **B15** (sort por coluna) + **A11** na semana de onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E8** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa). **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); **E16** dossiê do município (pedido O6 — compõe o existente, melhora com E8); D2 se sobrar folga. diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index 89f83fb7..9148e696 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -7,6 +7,8 @@ import { CampaignDashboard } from '@/components/campaign/CampaignDashboard' import { LeaderContactsPanel } from '@/components/campaign/LeaderContactsPanel' import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext' import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic' +import { TerritoryOverviewTable } from '@/components/campaign/TerritoryOverviewTable' +import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell' import type { CampaignUser } from '@/payload-types' import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' @@ -14,6 +16,7 @@ import { getCampaignDashboardData } from '@/utilities/campaignDashboardData' import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData' +import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview' export const dynamic = 'force-dynamic' @@ -63,6 +66,20 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag ) : null } + territorySlot={ + isStaff ? ( + + ) : null + } /> ) } @@ -88,3 +105,18 @@ const DashboardMapSection = async ({ ) } + +const TerritoryOverviewSection = async ({ payload }: { payload: Payload }) => { + const rows = await loadTerritoryOverview(payload) + return ( +
+

+ Territórios de Identidade +

+ +
+ ) +} diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index fa2cea80..aacbcf64 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -27,11 +27,14 @@ 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 }) => (
@@ -73,6 +76,8 @@ export const CampaignDashboard = ({ /> + {territorySlot} +
`${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