Merge pull request #471 from fsolla/B175-repintar-a-lista-de-territorios-no-padrao-da-lista-de-m
B175 — Repintar a lista de Territórios no padrão da lista de Municípios
This commit is contained in:
commit
9f997aa232
14 changed files with 798 additions and 296 deletions
95
docs/plans/repintar-lista-territorios-impl.md
Normal file
95
docs/plans/repintar-lista-territorios-impl.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Impl: Repintar a lista de Territórios no padrão da lista de Municípios
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #451
|
||||
Intenção: docs/plans/repintar-lista-territorios.md
|
||||
Appetite restante: herdado (~1,5–2 dias eng); sem corte — a abordagem reusa o padrão B158 das colunas responsivas, não constrói novo.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `/campanha/territorios` passa ao padrão da lista de Municípios (B158): headers curtos, contagem de municípios colada ao nome do TI, % e votos absorvidos na célula de 2022, célula de 2026 no detalhe de Municípios (valor + faixa de cenários no hover, só leitura), novas colunas de rede só leitura (Assessor, Liderança, Dobradinha), `Cobertura` oculta por padrão no seletor, tabela responsiva à largura do painel sem rolagem horizontal, ordem visual fixa `<Território><2022><Captura><2026><Assessor><Liderança><Dobradinha><Classe><Assessoria><Cobertura>`.
|
||||
- **O que NÃO negociar:** barreira `noLeader` intacta; leitura permanece por agregado por TI (soma/contagem, nunca dado de município individual); sub-linhas do Metropolitano seguem o pai (nunca ordenam sozinhas); sort default contínua `pct` desc; contratos de URL intactos (sort keys `region|municipalities|votes2022|pct|validVotes2022|estimate2026|coverage|cobertura|captura|classe`, filtros `region`/`coverage`); sem migração; sem Consent; formulários de visualização para 2026 e rede ficam fora (nenhuma célula abre editor).
|
||||
- **O que reavaliar:** a hipótese apontava células reutilizando os componentes de Municípios (`MunicipalityVotePositionReadout`, editor de 2026). Ajuste: as células de território reutilizam **formatters** e o **padrão visual**, mas são read-outs próprios (sem `rank` por TI; sem editor; sem série 2014/2018). A hipótese também não resolveu o acesso da rede por TI — decidido abaixo.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[territorios/page.tsx] --> LO[loadTerritoryOverview → bundle rows+references]
|
||||
LO --> ROLLUP[computeTerritoryRollup puro]
|
||||
LO -.-> REF[advisorNamesById / leadershipNamesById / stateDeputyById]
|
||||
P --> TL[TerritoryList @container/territory-list]
|
||||
TL --> COLS[TerritoryListColumns responsivas + novas células read-only]
|
||||
COLS -.-> SH[TerritorySortableHead]
|
||||
COLS -.-> AV[MunicipalityRelationAvatarStack]
|
||||
COLS -.-> STRIP[VoteEstimateScenarioStrip hover]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (fiar no bundle único do loader + padrão B158) | B (carregar rede em loader separado do overview p/ homeSearch não pagar) | C (reusar 1:1 os componentes editáveis de Municípios)
|
||||
**Recomendação:** **A** — um `loadTerritoryOverview` que devolve `{ rows, references }`; as consultas de rede são poucas e pequenas (advisor names por ids, batch reverso de lideranças por município, catálogo de dobradinhas) e o home-search (`searchHomeMunicipalities`) passa a usar `.rows` — o custo adicional (3–4 queries pequenas) é aceitável contra a coesão de UM loader e zero leituras duplicadas de `municipality`. O `Termo` `TerritoryReader` (union `CampaignUser | User`) é apertado para `CampaignUser` — os dois call sites passam `CampaignUser`.
|
||||
**Rejeitadas:** B porque duplicaria a leitura de municípios e criaria um segundo módulo de domínio para o mesmo bundle (smell da Depth Check); C porque traria editores e `rank`/série 2014-2018 que o gate explicitamente cortou e duplicaria tooltip no mesmo gesto.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`territoryOverview.ts`** (`src/utilities/territory/`, puro/client-safe): estende `TerritoryMunicipalityInput` com `estimateByScenario: Record<VoteEstimateScenario, number>`, `hasEstimate: boolean`, e os conjuntos `advisorIDs: number[]`, `leadershipIDs: number[]`, `stateDeputyIDs: number[]` (elimina `advisorCount` redundante — cobertura deriva de `advisorIDs.length > 0`). O rollup (`computeTerritoryRollup`) passa a somar por cenário (`estimateByScenario`), OR de `hasEstimate` e união (dedup) dos três conjuntos por TI — covariando nas sub-linhas do Metropolitano. `sortTerritoryRows` para `estimate2026` usará `estimateByScenario.central`.
|
||||
- **`loadTerritoryOverview.ts`** (`server-only`): novo retorno `{ rows, references }`; `references = { advisorNamesById, leadershipNamesById, stateDeputyById }` via `loadAdvisorSummaries` + `loadMunicipalityLeadershipSummaries` (batch reverso por municípios, honra `canReadLeadership`, fail-closed) + catálogo `loadStateDeputyOptions` (honra `canReadStateDeputy`). Por município: `advisorIDs` de `doc.advisors`, `stateDeputyIDs` de `doc.stateDeputies` (select novo), `leadershipIDs` do batch reverso; `estimateByScenario` por `resolveMunicipalityStaffVoteTotalForScenario`; `hasEstimate` = `hasAnyVoteEstimate(expectedVotes)` OU `pledgeAggregate.declaredTotal > 0`.
|
||||
- **`searchHomeMunicipalities.ts`**: consome `.rows` do novo retorno (não usa rede).
|
||||
- **`territoryListUrl.ts` / `territoryListLabels.ts`**: labels de coluna curtos — `votes2022: '2022'`, `estimate2026: '2026'`, `captura: 'Captura'`, `cobertura: 'Cobertura'`; removem-se as descrições mortas (`municipalities`, `pct`, `validVotes2022` — colunas saem da tabela e o tooltip de header não as usa). **Não** se remove nenhuma sort key (todas permanecem válidas via omnibox/resumo — só o header clicável some).
|
||||
- **`campaignColumnVisibility.ts`** (`lib/`): `DEFAULT_HIDDEN_COLUMN_IDS.territorios = ['cobertura']` (Espelho de `municipios: ['goalCoverage','lastSignal']`).
|
||||
- **`TerritoryList.tsx`**: envolve a tabela em `<div data-container="territory-list" className="@container/territory-list">`; `CampaignTable` com `containerClassName="overflow-x-auto supports-[container-type:inline-size]:overflow-x-hidden"` e `overflow-visible`; passa `references` às colunas. Sem aba mobile (a leitura estreita para no núcleo P0 — intenção).
|
||||
- **`TerritoryListColumns.tsx`**: nova ordem visual fixa e classes responsivas por coluna — cada coluna não-core usa `hidden` combinado com o rung de container query (`@container/territory-list`), virando `table-cell` a partir de um breakpoint próprio em `rem`:
|
||||
- `region` (mandatory, sticky left, `min-w-56`): name-link + ` (N)` da contagem (`Metropolitano de Salvador (40)`; sub-linhas `Salvador (19)` / `Demais RMS (21)` — mesma convenção).
|
||||
- `votes2022` (header `2022`, P0): célula `% (primária) + votos (linha secundária)` — read-out próprio (formatters `formatVoteSharePercent`/`formatElectionNumber`; sem `rank`; sem curva 2014/2018); `cellTooltip` = **exclusivamente** os votos válidos 2022 (`Votos válidos 2022: N`).
|
||||
- `captura` (header `Captura`, P1 `@min-[46rem]`): célula atual (taxa de captura agregada), `cellTooltip` atual (mediana/amplitude/município crítico/beacon) — inalterada.
|
||||
- `estimate2026` (header `2026`, P0): read-out leitura — valor `estimateByScenario.central` + `%` como linha secundária; `—` quando `!hasEstimate`; `cellTooltip` = `VoteEstimateScenarioStrip` dos 3 cenários (`labelMode="endpoints"`, `markerMode="active-only"`, espelho do hover compacto de Municípios). Sem editor, sem seletor de cenário (lista lê o cenário default — intenção).
|
||||
- `advisor` (header `Assessor`, P2 `@min-[62rem]`): read-only via `MunicipalityRelationAvatarStack` (avatars + nomes no tooltip — padrão Municípios); vazio = `—` mudo (o alerta de lacuna continua na Assessoria); aporta `advisorNamesById`.
|
||||
- `leadership` (header `Liderança`, P2 `@min-[68rem]`): read-only (avatars, href `/campanha/liderancas/<id>`, nomes no tooltip); vazio = `—`.
|
||||
- `stateDeputy` (header `Dobradinha`, P2 `@min-[74rem]`): read-only (avatars, href `/campanha/dobradinhas/<id>`, nome+partido no tooltip); vazio = `—`. **Coluna para TODO o staff** (decisão do gate humano 2026-08-08): nomes via catálogo `loadStateDeputyOptions` (honra `canReadStateDeputy` = todo staff), sem restrição de papel na coluna.
|
||||
- `classe` (header `Classe`, P1 `@min-[52rem]`): célula atual inalterada.
|
||||
- `coverage` (header `Assessoria`, P1 `@min-[56rem]`): célula atual (`X de N`) + filtro `coverage` no header (inalterado).
|
||||
- `cobertura` (header `Cobertura`, P3 `@min-[80rem]`): célula atual (ratio + déficit), oculta por padrão no seletor.
|
||||
- Saem da tabela: `municipalities`, `pct`, `validVotes2022` (absorvidas; sort keys preservadas via omnibox/resumo).
|
||||
- **Novos read-outs** ficam inline em `TerritoryListColumns.tsx` (componentes pequenos no próprio arquivo — 3 células pequenas + 2 read-outs; sem módulos novos só para células de 1 call site, contra a Depth Check).
|
||||
- **`TerritorySortableHead.tsx`**: sem mudança estrutural (labels vêm de `territoryListSortLabels`).
|
||||
- **Migration:** sem migration (nenhuma mudança de schema).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma: cell 2022 = % primário + votos secundário (espelho `MunicipalityVotePositionReadout`, sem `rank`); 2026 = valor central + % secundário, faixa de cenários só no hover (espelho do compacto de `StaffMunicipalityVotesDisplay`). Rejeitadas: manter coluna `% da própria votação` (o painel estourava — objeto do item); válidos na célula (empilharia 3 linhas no mesmo gesto — gate); série 2014/2018 no hover (decisão do gate: sai; hover = só válidos).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema/server** — `territoryOverview.ts` (input/rollup/sort) + `loadTerritoryOverview.ts` (bundle+references) + `searchHomeMunicipalities.ts` (`.rows`) + `tests/unit/territoryOverview.unit.spec.ts` (fixture com novos campos; asserts de soma por cenário, `hasEstimate`, uniões de advisor/leadership/stateDeputy, sub-linhas).
|
||||
2. **UI** — labels (listUrl/listLabels), default-hidden (campaignColumnVisibility + teste unit), `TerritoryList.tsx` (container), `TerritoryListColumns.tsx` (ordem, células, responsivas, células novas), picker (passa as colunas novas; `stateDeputy` condicional por unrestricted).
|
||||
3. **Gates** — `pnpm gate:fast` em iteração, e2e (`campaignTerritories.e2e.spec.ts`: atualizar `Ordenar por Votos 2022` → `Ordenar por 2022`; assert regex de contagem `(N)` e de colunas novas), `pnpm format:check`, `knip`, `check:cycles`, `pnpm build`; entrega via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não adicionar seletor de cenário ao território (fora de escopo da intenção — lista lê o cenário default).
|
||||
- Não criar módulo novo para as células (1 call site; inline no arquivo de colunas).
|
||||
- Não mexer em fórmulas (captura/cobertura/%) nem na malha/`territorialClass`.
|
||||
- Não aplicar o padrão às demais listas (lideranças, dobradinhas, organizações…).
|
||||
- Não tocar em migration/schema/Consent.
|
||||
- Não reabrir cards mobile de territórios (não existem; a estreiteza para no núcleo P0).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Breakpoints de container** (P1/P2/P3): valores concretos são ajustáveis (baratos) — começar com os acima e afinar pelo widget/preview; registrar gatilho de revisitação no CHANGELOG se o painel mostrar degraus feios.
|
||||
- **e2e sensível a label**: `Ordenar por Votos 2022` → `Ordenar por 2022` (aria-label vem de `territoryListSortLabels.votes2022`).
|
||||
- **Acesso da rede por TI**: nomes de assessores via `loadAdvisorSummaries` (todos os staff; drop de ids não-elegíveis, fail-closed); nomes de lideranças via `loadMunicipalityLeadershipSummaries` (honra `canReadLeadership` — assessor só vê lideranças dos municípios que administra; fora do escopo, célula vazia `—`, fail-closed); **Dobradinha para todo o staff** (decisão do gate humano 2026-08-08; `canReadStateDeputy` = todo staff). Sem overrideAccess: false nos loads de rede.
|
||||
- **Performance**: rollup permanece puro e O(n) por TI; referências usam poucos `where in` (não N+1 por TI).
|
||||
|
||||
## Decisões de engenharia (formato obrigatório)
|
||||
|
||||
1. **Loader de rede**: Opções: bundle único | loader separado | reusar componentes editáveis. Recomendação: bundle único (`loadTerritoryOverview` → `{ rows, references }`). Rejeitadas: loader separado (2º módulo + leitura dupla de municípios), componentes de Municípios 1:1 (editores/rank/série fora do gate).
|
||||
2. **Células 2022/2026**: Opções: reusar `MunicipalityVotePositionReadout`/`StaffMunicipalityVotesDisplay` | read-outs próprios com formatters. Recomendação: read-outs próprios (hospedados no arquivo de colunas), reusando `formatVoteSharePercent`/`formatElectionNumber` e `VoteEstimateScenarioStrip`. Rejeitadas: reuso direto (arrastaria `rank`/`totalUnits` e o layout de editor/pledge).
|
||||
3. **Assessor vazio**: Opções: badge `MissingAdvisorBadge` (padrão Municípios) | `—` mudo. Recomendação: `—` mudo — o alarme de lacuna já vive na coluna Assessoria (`X de N`), duplicar o mesmo aviso no mesmo gesto é ruído.
|
||||
4. **Coluna Dobradinha por papel**: Opções: restringir a `isCampaignUnrestricted` (consistência com B157) | expor a todo o staff (leitura simples da intenção + `canReadStateDeputy` já é todo staff). Recomendação (confirmada no gate humano 2026-08-08): **expor a todo o staff** — nenhum editável é duplicado e a leitura é agregada por TI; o acesso de dados já permite.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (headers curtos, `(N)` no nome, %/válidos absorvidos, 2026 espelho, rede leitura, Cobertura oculta, sem scroll, ordem fixa)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem overrideAccess: false na rede; sort/URL/filtros conservados; copy pt-BR / ids em inglês; sem migration; sem Consent)
|
||||
- [x] Testes de domínio previstos: unit (rollup puro — cenários, hasEstimate, uniões, Metropolitano) + e2e (labels, contagem, colunas novas, url/âncoras) + campaignColumnVisibility (default oculto de territorios)
|
||||
- [x] Self-score decision-quality: 5/5 (decididas com rejeitadas; cabe no appetite; rabbit holes nomeados; reuso de shells; intenção preservada)
|
||||
|
|
@ -4,7 +4,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
|
||||
import { TerritoryList } from '@/components/campaign/municipality/TerritoryList'
|
||||
import { territoryListColumns } from '@/components/campaign/municipality/TerritoryListColumns'
|
||||
import { territoryListPickerColumns } from '@/components/campaign/municipality/TerritoryListColumns'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -39,7 +38,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const allRows = await loadTerritoryOverview(payload, user)
|
||||
const { rows: allRows, references } = await loadTerritoryOverview(payload, user)
|
||||
const columnVisibility = await readCampaignColumnVisibility('territorios')
|
||||
const { state } = resolvedUrl
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
|
|
@ -57,9 +56,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={toCampaignColumnPickerColumns(
|
||||
territoryListColumns({ state, regionOptions: regionOptions }),
|
||||
)}
|
||||
columns={territoryListPickerColumns()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
@ -68,6 +65,7 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
|
|||
rows={rows}
|
||||
state={state}
|
||||
regionOptions={regionOptions}
|
||||
references={references}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { territoryAnchorId } from '@/lib/territoryAnchor'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TerritoryNetworkReferences } from '@/utilities/territory/loadTerritoryOverview'
|
||||
import {
|
||||
clearTerritoryListFilters,
|
||||
type TerritoryFilterOption,
|
||||
|
|
@ -58,31 +59,37 @@ export const TerritoryList = ({
|
|||
rows,
|
||||
state,
|
||||
regionOptions,
|
||||
references,
|
||||
columnVisibility,
|
||||
}: {
|
||||
rows: TerritoryOverviewRow[]
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
references: TerritoryNetworkReferences
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
}) => {
|
||||
const { sort, dir } = resolveTerritoryListSort(state)
|
||||
const sortSummary = formatTerritoryListSortSummary(sort, dir)
|
||||
|
||||
return (
|
||||
<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 })}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) => (row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`)}
|
||||
rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)}
|
||||
rowClassName={(row) =>
|
||||
row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50'
|
||||
}
|
||||
empty={<TerritoryListEmptyState state={state} />}
|
||||
/>
|
||||
<div data-container="territory-list" className="@container/territory-list">
|
||||
<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 supports-[container-type:inline-size]:overflow-x-hidden"
|
||||
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, references })}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) =>
|
||||
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
|
||||
}
|
||||
rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)}
|
||||
rowClassName={(row) =>
|
||||
row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50'
|
||||
}
|
||||
empty={<TerritoryListEmptyState state={state} />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import Link from 'next/link'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
import { TerritorySortableHead } from '@/components/campaign/municipality/TerritorySortableHead'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import type { MunicipalityRelationEntry } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import { MunicipalityRelationAvatarStack } from '@/components/campaign/shared/MunicipalityRelationAvatarStack'
|
||||
import { VoteEstimateScenarioStrip } from '@/components/campaign/votePledge/VoteEstimateScenarioStrip'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
|
||||
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
|
||||
import { formatVoteEstimateScenarioAriaLabel } from '@/lib/voteEstimate'
|
||||
import {
|
||||
formatGoalCoverageDeficitShortLabel,
|
||||
formatGoalCoverageRatioLabel,
|
||||
|
|
@ -15,6 +24,10 @@ import {
|
|||
territorialClassBadgeVariant,
|
||||
territorialClassLabels,
|
||||
} from '@/utilities/municipality/municipalityLabels'
|
||||
import type {
|
||||
TerritoryNetworkReferences,
|
||||
TerritoryStateDeputyReference,
|
||||
} from '@/utilities/territory/loadTerritoryOverview'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territory/territoryListFilters'
|
||||
import { territoryColumnDescriptions } from '@/utilities/territory/territoryListLabels'
|
||||
import {
|
||||
|
|
@ -23,34 +36,6 @@ import {
|
|||
} from '@/utilities/territory/territoryListUrl'
|
||||
import type { TerritoryTableRow } from '@/utilities/territory/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,
|
||||
|
|
@ -139,189 +124,373 @@ const captureTooltipContent = (row: TerritoryTableRow) => {
|
|||
)
|
||||
}
|
||||
|
||||
/** B175 — 2022 cell mirroring the municípios pattern: % first, votes as the meta line. */
|
||||
const TerritoryVoteReadout = ({ row }: { row: TerritoryTableRow }) => {
|
||||
const votes = row.votesByYear['2022'] ?? 0
|
||||
const share = row.pctPropriaVotacao
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-end gap-0.5 tabular-nums"
|
||||
aria-label={`${formatVoteSharePercent(share)} da votação estadual, ${formatElectionNumber(votes)} votos`}
|
||||
>
|
||||
<span className="font-medium">{formatVoteSharePercent(share)}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatElectionNumber(votes)}</span>
|
||||
{/* The valid votes live only in the hover — keep them as sr-only text so the tooltip repeats, never invents. */}
|
||||
<span className="sr-only">
|
||||
Votos válidos de deputado federal em 2022 no território:{' '}
|
||||
{formatElectionNumber(row.validVotes2022)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** B175 — 2026 read-only cell: central-scenario total + % as a secondary line; scenarios on hover. */
|
||||
const TerritoryEstimateReadout = ({ row }: { row: TerritoryTableRow }) => {
|
||||
if (!row.hasEstimate) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-0.5 tabular-nums">
|
||||
<span className="font-medium">{formatElectionNumber(row.estimateByScenario.central)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatVoteSharePercent(row.pctPropriaVotacao)}
|
||||
</span>
|
||||
{/* Same redundancy contract as the cell's hover strip (municípios precedent). */}
|
||||
<span className="sr-only">{formatVoteEstimateScenarioAriaLabel(row.estimateByScenario)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B175 — read-only network columns (Assessor / Liderança / Dobradinha)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const territoryNetworkColumnDescriptions = {
|
||||
advisor:
|
||||
'Assessores de staff atribuídos nos municípios do território. Nomes no detalhe da célula.',
|
||||
leadership: 'Lideranças vinculadas aos municípios do território. Nomes no detalhe da célula.',
|
||||
stateDeputy:
|
||||
'Deputados estaduais em dobradinha nos municípios do território. Nomes no detalhe da célula.',
|
||||
} as const
|
||||
|
||||
const territoryNetworkColumnLabels = {
|
||||
advisor: 'Assessor',
|
||||
leadership: 'Liderança',
|
||||
stateDeputy: 'Dobradinha',
|
||||
} as const
|
||||
|
||||
const advisorEntries = (
|
||||
row: TerritoryTableRow,
|
||||
advisorNamesById: TerritoryNetworkReferences['advisorNamesById'],
|
||||
): MunicipalityRelationEntry[] =>
|
||||
row.advisorIDs.flatMap((id) => {
|
||||
const advisor = advisorNamesById.get(id)
|
||||
return advisor ? [{ id: advisor.id, label: advisor.name }] : []
|
||||
})
|
||||
|
||||
const leadershipEntries = (
|
||||
row: TerritoryTableRow,
|
||||
leadershipNamesById: TerritoryNetworkReferences['leadershipNamesById'],
|
||||
): MunicipalityRelationEntry[] =>
|
||||
row.leadershipIDs.flatMap((id) => {
|
||||
const leadership = leadershipNamesById.get(id)
|
||||
return leadership
|
||||
? [
|
||||
{
|
||||
id: leadership.id,
|
||||
label: leadership.name,
|
||||
href: `/campanha/liderancas/${leadership.id}`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
})
|
||||
|
||||
const stateDeputyEntries = (
|
||||
row: TerritoryTableRow,
|
||||
stateDeputyById: TerritoryNetworkReferences['stateDeputyById'],
|
||||
): MunicipalityRelationEntry[] =>
|
||||
row.stateDeputyIDs.flatMap((id) => {
|
||||
const deputy: TerritoryStateDeputyReference | undefined = stateDeputyById.get(id)
|
||||
if (!deputy) return []
|
||||
return [
|
||||
{
|
||||
id: deputy.id,
|
||||
label: stateDeputyDisplayName(deputy.plainName, deputy.party),
|
||||
initialsLabel: deputy.plainName,
|
||||
href: deputy.href,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const namesTooltip = (entries: MunicipalityRelationEntry[]): ReactElement | null =>
|
||||
entries.length === 0 ? null : (
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry) => (
|
||||
<span key={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const NetworkCell = ({ entries }: { entries: MunicipalityRelationEntry[] }) => (
|
||||
<MunicipalityRelationAvatarStack
|
||||
entries={entries}
|
||||
emptyState={<span className="text-muted-foreground">—</span>}
|
||||
maxVisible={3}
|
||||
/>
|
||||
)
|
||||
|
||||
/**
|
||||
* B175 — column widths keep the density ladder: P0 core is always on, one
|
||||
* column joins per panel-width rung, never a horizontal scrollbar.
|
||||
*/
|
||||
const responsiveColumnClassName = {
|
||||
captura: 'hidden @min-[44rem]/territory-list:table-cell',
|
||||
classe: 'hidden @min-[50rem]/territory-list:table-cell',
|
||||
coverage: 'hidden @min-[54rem]/territory-list:table-cell',
|
||||
advisor: 'hidden @min-[60rem]/territory-list:table-cell',
|
||||
leadership: 'hidden @min-[66rem]/territory-list:table-cell',
|
||||
stateDeputy: 'hidden @min-[72rem]/territory-list:table-cell',
|
||||
cobertura: 'hidden @min-[78rem]/territory-list:table-cell',
|
||||
} as const
|
||||
|
||||
type TerritoryListColumnsProps = {
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
references: TerritoryNetworkReferences
|
||||
}
|
||||
|
||||
export const territoryListColumns = ({
|
||||
state,
|
||||
regionOptions,
|
||||
}: {
|
||||
state: TerritoryListState
|
||||
regionOptions: TerritoryFilterOption[]
|
||||
}): Array<CampaignTableColumn<TerritoryTableRow>> => [
|
||||
{
|
||||
id: 'region',
|
||||
mandatory: true,
|
||||
label: territoryListSortLabels.region,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={regionOptions}
|
||||
description={territoryColumnDescriptions.region}
|
||||
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>
|
||||
references,
|
||||
}: TerritoryListColumnsProps): Array<CampaignTableColumn<TerritoryTableRow>> => {
|
||||
const { advisorNamesById, leadershipNamesById, stateDeputyById } = references
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'region',
|
||||
mandatory: true,
|
||||
label: territoryListSortLabels.region,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="region"
|
||||
filterParam="region"
|
||||
filterOptions={regionOptions}
|
||||
description={territoryColumnDescriptions.region}
|
||||
className="sticky left-0 z-20 min-w-56 bg-background"
|
||||
/>
|
||||
),
|
||||
cellClassName: (row) =>
|
||||
`sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`,
|
||||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
label: territoryListSortLabels.municipalities,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="municipalities"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.municipalities}
|
||||
/>
|
||||
),
|
||||
cell: (row) => row.municipalityCount,
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
},
|
||||
{
|
||||
id: 'votes2022',
|
||||
label: territoryListSortLabels.votes2022,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="votes2022"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.votes2022}
|
||||
/>
|
||||
),
|
||||
cell: (row) => <YearSeriesCell votesByYear={row.votesByYear} />,
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
},
|
||||
{
|
||||
id: 'pct',
|
||||
label: territoryListSortLabels.pct,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="pct"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.pct}
|
||||
/>
|
||||
),
|
||||
cell: (row) => formatPercent(row.pctPropriaVotacao),
|
||||
cellClassName: (row) =>
|
||||
`text-right tabular-nums ${row.variant === 'parent' ? 'font-medium' : 'text-muted-foreground'}`,
|
||||
},
|
||||
{
|
||||
id: 'validVotes2022',
|
||||
label: territoryListSortLabels.validVotes2022,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="validVotes2022"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.validVotes2022}
|
||||
/>
|
||||
),
|
||||
cell: (row) => formatElectionNumber(row.validVotes2022),
|
||||
cellClassName: 'text-right tabular-nums text-muted-foreground',
|
||||
},
|
||||
{
|
||||
id: 'estimate2026',
|
||||
label: territoryListSortLabels.estimate2026,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="estimate2026"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.estimate2026}
|
||||
/>
|
||||
),
|
||||
cell: (row) => formatElectionNumber(row.estimate2026),
|
||||
cellClassName: (row) =>
|
||||
`text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'cobertura',
|
||||
label: territoryListSortLabels.cobertura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="cobertura"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.cobertura}
|
||||
/>
|
||||
),
|
||||
cell: (row) => (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatGoalCoverageRatioLabel(row.goalCoverage)}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground tabular-nums"
|
||||
title="Déficit agregado (cenário média)"
|
||||
cell: (row) =>
|
||||
row.variant === 'parent' ? (
|
||||
<CampaignTransitionAnchor
|
||||
href={`/campanha/municipios?region=${encodeURIComponent(row.region)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.region}{' '}
|
||||
<span className="text-muted-foreground tabular-nums">({row.municipalityCount})</span>
|
||||
</CampaignTransitionAnchor>
|
||||
) : (
|
||||
// The Metropolitano sub-labels already carry a count group ("Salvador (19 zonas)"),
|
||||
// so the count rides the name only when the label has none — never stacking "(19 zonas) (19)".
|
||||
<span className="pl-4 text-muted-foreground">
|
||||
{row.label}{' '}
|
||||
{/\(\d[^)]*\)$/.test(row.label) ? null : (
|
||||
<span className="tabular-nums">({row.municipalityCount})</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
cellClassName: (row) =>
|
||||
`sticky left-0 z-[5] min-w-56 ${row.variant === 'sub' ? 'bg-muted' : 'bg-background'}`,
|
||||
},
|
||||
{
|
||||
id: 'votes2022',
|
||||
label: territoryListSortLabels.votes2022,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="votes2022"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.votes2022}
|
||||
/>
|
||||
),
|
||||
cell: (row) => <TerritoryVoteReadout row={row} />,
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
// The valid-votes figure is the cell's exclusive hover content (B175).
|
||||
cellTooltip: (row) =>
|
||||
`Votos válidos de deputado federal em 2022 no território: ${formatElectionNumber(row.validVotes2022)}`,
|
||||
},
|
||||
{
|
||||
id: 'captura',
|
||||
label: territoryListSortLabels.captura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="captura"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.captura}
|
||||
className={responsiveColumnClassName.captura}
|
||||
/>
|
||||
),
|
||||
cell: (row) => formatRatioAsPercentLabel(row.captureRate),
|
||||
cellClassName: (row) =>
|
||||
`${responsiveColumnClassName.captura} text-right tabular-nums ${
|
||||
row.variant !== 'parent' ? 'text-muted-foreground' : ''
|
||||
}`,
|
||||
cellTooltip: (row) => captureTooltipContent(row),
|
||||
},
|
||||
{
|
||||
id: 'estimate2026',
|
||||
label: territoryListSortLabels.estimate2026,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="estimate2026"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.estimate2026}
|
||||
/>
|
||||
),
|
||||
cell: (row) => <TerritoryEstimateReadout row={row} />,
|
||||
cellClassName: (row) =>
|
||||
`text-right tabular-nums ${row.variant === 'sub' ? 'text-muted-foreground' : ''}`,
|
||||
cellTooltip: (row) =>
|
||||
row.hasEstimate ? (
|
||||
<div className="w-44">
|
||||
<VoteEstimateScenarioStrip
|
||||
values={row.estimateByScenario}
|
||||
labelMode="endpoints"
|
||||
markerMode="active-only"
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
id: 'advisor',
|
||||
label: territoryNetworkColumnLabels.advisor,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={territoryNetworkColumnDescriptions.advisor}
|
||||
className={responsiveColumnClassName.advisor}
|
||||
>
|
||||
{formatGoalCoverageDeficitShortLabel(row.goalCoverage)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
},
|
||||
{
|
||||
id: 'captura',
|
||||
label: territoryListSortLabels.captura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="captura"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.captura}
|
||||
/>
|
||||
),
|
||||
cell: (row) => formatRatioAsPercentLabel(row.captureRate),
|
||||
cellClassName: 'text-right tabular-nums',
|
||||
cellTooltip: (row) => captureTooltipContent(row),
|
||||
},
|
||||
{
|
||||
id: 'classe',
|
||||
label: territoryListSortLabels.classe,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="classe"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.classe}
|
||||
/>
|
||||
),
|
||||
cell: (row) => <TerritorialClassCell row={row} />,
|
||||
cellClassName: 'text-right',
|
||||
cellTooltip: (row) =>
|
||||
row.territorialClass ? formatTerritorialClassWhy(row.territorialClass.factors) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
label: territoryListSortLabels.coverage,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
align="right"
|
||||
filterParam="coverage"
|
||||
description={territoryColumnDescriptions.coverage}
|
||||
/>
|
||||
),
|
||||
cell: (row) => (
|
||||
<CoverageCell
|
||||
withAdvisorCount={row.withAdvisorCount}
|
||||
total={row.municipalityCount}
|
||||
muted={row.variant === 'sub'}
|
||||
/>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
},
|
||||
{territoryNetworkColumnLabels.advisor}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.advisor,
|
||||
cell: (row) => <NetworkCell entries={advisorEntries(row, advisorNamesById)} />,
|
||||
cellTooltip: (row) => namesTooltip(advisorEntries(row, advisorNamesById)),
|
||||
},
|
||||
{
|
||||
id: 'leadership',
|
||||
label: territoryNetworkColumnLabels.leadership,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={territoryNetworkColumnDescriptions.leadership}
|
||||
className={responsiveColumnClassName.leadership}
|
||||
>
|
||||
{territoryNetworkColumnLabels.leadership}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.leadership,
|
||||
cell: (row) => <NetworkCell entries={leadershipEntries(row, leadershipNamesById)} />,
|
||||
cellTooltip: (row) => namesTooltip(leadershipEntries(row, leadershipNamesById)),
|
||||
},
|
||||
{
|
||||
id: 'stateDeputy',
|
||||
label: territoryNetworkColumnLabels.stateDeputy,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
description={territoryNetworkColumnDescriptions.stateDeputy}
|
||||
className={responsiveColumnClassName.stateDeputy}
|
||||
>
|
||||
{territoryNetworkColumnLabels.stateDeputy}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.stateDeputy,
|
||||
cell: (row) => <NetworkCell entries={stateDeputyEntries(row, stateDeputyById)} />,
|
||||
cellTooltip: (row) => namesTooltip(stateDeputyEntries(row, stateDeputyById)),
|
||||
},
|
||||
{
|
||||
id: 'classe',
|
||||
label: territoryListSortLabels.classe,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="classe"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.classe}
|
||||
className={responsiveColumnClassName.classe}
|
||||
/>
|
||||
),
|
||||
cell: (row) => <TerritorialClassCell row={row} />,
|
||||
cellClassName: (row) =>
|
||||
`${responsiveColumnClassName.classe} text-right ${
|
||||
row.variant !== 'parent' ? 'text-muted-foreground' : ''
|
||||
}`,
|
||||
cellTooltip: (row) =>
|
||||
row.territorialClass ? formatTerritorialClassWhy(row.territorialClass.factors) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'coverage',
|
||||
label: territoryListSortLabels.coverage,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="coverage"
|
||||
align="right"
|
||||
filterParam="coverage"
|
||||
description={territoryColumnDescriptions.coverage}
|
||||
className={responsiveColumnClassName.coverage}
|
||||
/>
|
||||
),
|
||||
cell: (row) => (
|
||||
<CoverageCell
|
||||
withAdvisorCount={row.withAdvisorCount}
|
||||
total={row.municipalityCount}
|
||||
muted={row.variant === 'sub'}
|
||||
/>
|
||||
),
|
||||
cellClassName: `${responsiveColumnClassName.coverage} text-right`,
|
||||
},
|
||||
{
|
||||
id: 'cobertura',
|
||||
label: territoryListSortLabels.cobertura,
|
||||
head: (
|
||||
<TerritorySortableHead
|
||||
state={state}
|
||||
sortKey="cobertura"
|
||||
align="right"
|
||||
description={territoryColumnDescriptions.cobertura}
|
||||
className={responsiveColumnClassName.cobertura}
|
||||
/>
|
||||
),
|
||||
cell: (row) => (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatGoalCoverageRatioLabel(row.goalCoverage)}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground tabular-nums"
|
||||
title="Déficit agregado (cenário média)"
|
||||
>
|
||||
{formatGoalCoverageDeficitShortLabel(row.goalCoverage)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
cellClassName: `${responsiveColumnClassName.cobertura} text-right`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** B17 — the picker list; `region` is mandatory, the rest follow the visual order. */
|
||||
export const territoryListPickerColumns = () => [
|
||||
{ id: 'region', label: territoryListSortLabels.region, mandatory: true },
|
||||
{ id: 'votes2022', label: territoryListSortLabels.votes2022 },
|
||||
{ id: 'captura', label: territoryListSortLabels.captura },
|
||||
{ id: 'estimate2026', label: territoryListSortLabels.estimate2026 },
|
||||
{ id: 'advisor', label: territoryNetworkColumnLabels.advisor },
|
||||
{ id: 'leadership', label: territoryNetworkColumnLabels.leadership },
|
||||
{ id: 'stateDeputy', label: territoryNetworkColumnLabels.stateDeputy },
|
||||
{ id: 'classe', label: territoryListSortLabels.classe },
|
||||
{ id: 'coverage', label: territoryListSortLabels.coverage },
|
||||
{ id: 'cobertura', label: territoryListSortLabels.cobertura },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
|
|||
|
||||
const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[]>> = {
|
||||
municipios: ['goalCoverage', 'lastSignal'],
|
||||
// B175 — Cobertura is the deepest rung (P3) and only shows when the actor
|
||||
// opts in from the picker, mirroring the municipios precedent.
|
||||
territorios: ['cobertura'],
|
||||
}
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
|
|
|
|||
|
|
@ -48,10 +48,11 @@ export const searchHomeMunicipalities = async (
|
|||
|
||||
const normalizedQuery = normalizeHomeSearchName(query)
|
||||
|
||||
const [municipalityHits, territoryRows] = await Promise.all([
|
||||
const [municipalityHits, territoryOverview] = await Promise.all([
|
||||
searchStaffMunicipalityHits(payload, user, query),
|
||||
loadTerritoryOverview(payload, user),
|
||||
])
|
||||
const territoryRows = territoryOverview.rows
|
||||
const territoryHits: HomeSearchTerritoryHit[] = territoryRows
|
||||
.map((row) => {
|
||||
const normalizedName = normalizeHomeSearchName(row.region)
|
||||
|
|
|
|||
|
|
@ -5,28 +5,60 @@ import { cache } from 'react'
|
|||
|
||||
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
|
||||
import { slugsForMetropolitanoSubRowLabel } from '@/lib/metropolitanoTerritoryPeers'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality, User } from '@/payload-types'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
hasAnyVoteEstimate,
|
||||
resolveMunicipalityStaffVoteTotalForScenario,
|
||||
toVoteEstimateScenarioViewModel,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadMunicipalityScopeFromDocs } from '@/utilities/municipality/campaignMunicipalityScope'
|
||||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipality/municipalityGoalAccount'
|
||||
import { fieldCeiling, ownVotes2022 } from '@/utilities/municipality/municipalityPotential'
|
||||
import { computeAggregateTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
import {
|
||||
loadAdvisorSummaries,
|
||||
loadMunicipalityLeadershipSummaries,
|
||||
type MunicipalityAdvisorSummary,
|
||||
type MunicipalityLeadershipSummary,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import {
|
||||
computeTerritoryRollup,
|
||||
type TerritoryMunicipalityInput,
|
||||
type TerritoryOverviewRow,
|
||||
} from '@/utilities/territory/territoryOverview'
|
||||
import {
|
||||
emptyMunicipalityPledgeAggregate,
|
||||
resolveMunicipalityStaffVoteTotal,
|
||||
} from '@/utilities/votePledgeViews'
|
||||
import { emptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
|
||||
|
||||
type MunicipalityDoc = Pick<
|
||||
Municipality,
|
||||
'id' | 'slug' | 'name' | 'city' | 'region' | 'kind' | 'advisors' | 'expectedVotes'
|
||||
| 'id'
|
||||
| 'slug'
|
||||
| 'name'
|
||||
| 'city'
|
||||
| 'region'
|
||||
| 'kind'
|
||||
| 'advisors'
|
||||
| 'stateDeputies'
|
||||
| 'expectedVotes'
|
||||
>
|
||||
|
||||
type TerritoryReader = CampaignUser | User
|
||||
/** Aggregate name lookups the territory network columns render (B175). */
|
||||
export type TerritoryStateDeputyReference = {
|
||||
id: number
|
||||
plainName: string
|
||||
party: string | null
|
||||
href: string
|
||||
}
|
||||
|
||||
export type TerritoryNetworkReferences = {
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
stateDeputyById: ReadonlyMap<number, TerritoryStateDeputyReference>
|
||||
}
|
||||
|
||||
const attachTerritorialClasses = (
|
||||
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
|
||||
|
|
@ -50,19 +82,27 @@ const attachTerritorialClasses = (
|
|||
})
|
||||
|
||||
/**
|
||||
* E17 + E12 — Loads the full 27-TI overview (all 435 municipalities).
|
||||
* E17 + E12 + B175 — Loads the full 27-TI overview (all 435 municipalities)
|
||||
* plus the aggregate name lookups for the read-only network columns
|
||||
* (Assessor / Liderança / Dobradinha).
|
||||
*
|
||||
* Regional context is intentionally non-scoped: an advisor sees the complete
|
||||
* comparative table (leitura regional é contexto, não gestão). Exposure is
|
||||
* TI-level aggregates only — never per-municipality PII. Leaders never reach
|
||||
* this (the Início page routes them to the contact tool first).
|
||||
* TI-level aggregates only — never per-municipality PII. Network names honour
|
||||
* the existing read-access contracts (`canReadCampaignUsers` for advisors,
|
||||
* `canReadLeadership` for leaderships, `canReadStateDeputy` for dobradinhas),
|
||||
* so an advisor never learns leaderships outside the municipalities they
|
||||
* administer. Leaders never reach this (`gate: noLeader` on the page).
|
||||
*/
|
||||
export const loadTerritoryOverview = cache(
|
||||
async (
|
||||
payload: Payload,
|
||||
user: TerritoryReader,
|
||||
user: CampaignUser,
|
||||
scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
): Promise<TerritoryOverviewRow[]> => {
|
||||
): Promise<{
|
||||
rows: TerritoryOverviewRow[]
|
||||
references: TerritoryNetworkReferences
|
||||
}> => {
|
||||
const result = await payload.find({
|
||||
collection: 'municipality',
|
||||
depth: 0,
|
||||
|
|
@ -77,6 +117,7 @@ export const loadTerritoryOverview = cache(
|
|||
region: true,
|
||||
kind: true,
|
||||
advisors: true,
|
||||
stateDeputies: true,
|
||||
expectedVotes: true,
|
||||
},
|
||||
})
|
||||
|
|
@ -94,11 +135,15 @@ export const loadTerritoryOverview = cache(
|
|||
const inputs: TerritoryMunicipalityInput[] = docs.map((doc) => {
|
||||
const baseline = getMunicipalityFederalBaseline(doc.slug)
|
||||
const aggregate = aggregates.get(doc.id) ?? emptyMunicipalityPledgeAggregate
|
||||
const estimate = resolveMunicipalityStaffVoteTotal(
|
||||
doc.expectedVotes ?? null,
|
||||
aggregate.effectiveByScenario[scenario],
|
||||
scenario,
|
||||
)
|
||||
const expectedView = toVoteEstimateScenarioViewModel(doc.expectedVotes)
|
||||
const estimateByScenario = {} as Record<VoteEstimateScenario, number>
|
||||
for (const key of VOTE_ESTIMATE_SCENARIOS) {
|
||||
estimateByScenario[key] = resolveMunicipalityStaffVoteTotalForScenario(
|
||||
doc.expectedVotes,
|
||||
aggregate.effectiveByScenario[key],
|
||||
key,
|
||||
)
|
||||
}
|
||||
const coverageByScenario = coverageByMunicipalityID.get(doc.id)
|
||||
const goalCoverage = coverageByScenario?.[scenario] ?? {
|
||||
goal: 0,
|
||||
|
|
@ -115,15 +160,57 @@ export const loadTerritoryOverview = cache(
|
|||
kind: doc.kind,
|
||||
votesByYear: { ...baseline.votesByYear },
|
||||
validVotesByYear: { ...baseline.validVotesByYear },
|
||||
estimate2026: estimate,
|
||||
advisorCount: doc.advisors?.length ?? 0,
|
||||
estimateByScenario,
|
||||
hasEstimate: hasAnyVoteEstimate(expectedView) || aggregate.declaredTotal > 0,
|
||||
advisorIDs: (doc.advisors ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
stateDeputyIDs: (doc.stateDeputies ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
leadershipIDs: [],
|
||||
ownVotes2022: ownVotes2022(baseline),
|
||||
fieldCeiling2022: fieldCeiling(baseline),
|
||||
goalCoverage,
|
||||
}
|
||||
})
|
||||
|
||||
const rows = computeTerritoryRollup(inputs)
|
||||
return attachTerritorialClasses(inputs, rows)
|
||||
const municipalityIDs = docs.map((doc) => doc.id)
|
||||
const { leadershipIDsByMunicipality, summariesById: leadershipNamesById } =
|
||||
await loadMunicipalityLeadershipSummaries(payload, user, municipalityIDs)
|
||||
|
||||
for (let index = 0; index < docs.length; index += 1) {
|
||||
const leadershipIDs = leadershipIDsByMunicipality.get(docs[index]!.id)
|
||||
if (leadershipIDs) inputs[index]!.leadershipIDs = leadershipIDs
|
||||
}
|
||||
|
||||
const rows = attachTerritorialClasses(inputs, computeTerritoryRollup(inputs))
|
||||
|
||||
const usedStateDeputyIDs = new Set(inputs.flatMap((input) => input.stateDeputyIDs))
|
||||
const stateDeputyById = new Map<number, TerritoryStateDeputyReference>()
|
||||
if (usedStateDeputyIDs.size > 0) {
|
||||
for (const option of await loadStateDeputyOptions(payload, user)) {
|
||||
if (!usedStateDeputyIDs.has(option.id)) continue
|
||||
stateDeputyById.set(option.id, {
|
||||
id: option.id,
|
||||
plainName: option.plainName,
|
||||
party: option.party ?? null,
|
||||
href: `/campanha/dobradinhas/${option.id}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const usedAdvisorIDs = [...new Set(inputs.flatMap((input) => input.advisorIDs))]
|
||||
const advisorSummaries = await loadAdvisorSummaries(payload, user, usedAdvisorIDs)
|
||||
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
|
||||
|
||||
return {
|
||||
rows,
|
||||
references: {
|
||||
advisorNamesById,
|
||||
leadershipNamesById,
|
||||
stateDeputyById,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@ import type { TerritoryListSortKey } from '@/utilities/territory/territoryListUr
|
|||
/** B22 — header tooltips for `/campanha/territorios` (E12 columns included). */
|
||||
export const territoryColumnDescriptions: Partial<Record<TerritoryListSortKey, string>> = {
|
||||
region: 'Território de Identidade da Bahia — unidade de coordenação regional.',
|
||||
municipalities: 'Quantidade de municípios (ou zonas de Salvador) no território.',
|
||||
votes2022: 'Soma dos votos do Jorge Solla para deputado federal em 2022 no território.',
|
||||
pct: 'Quanto da votação própria do candidato na Bahia em 2022 veio deste território.',
|
||||
validVotes2022:
|
||||
'Soma dos votos válidos de deputado federal em 2022 nos municípios do território.',
|
||||
votes2022:
|
||||
'Porcentagem da votação própria do candidato na Bahia em 2022 e o nº de votos do território. Válidos de 2022 no detalhe da célula.',
|
||||
estimate2026:
|
||||
'Soma das estimativas de votos da mesa (cenário média) nos municípios do território.',
|
||||
'Soma das estimativas de votos da mesa (cenário central no número; faixa de cenários no detalhe da célula).',
|
||||
coverage: 'Quantos municípios têm pelo menos um assessor atribuído.',
|
||||
cobertura: `${campaignConceptOneLiner('cobertura-da-meta')} Soma metas e compromissos dos municípios do território (cenário média).`,
|
||||
captura: `${campaignConceptOneLiner('captura')} No território, usa a soma dos votos próprios ÷ soma dos tetos — nunca a média das capturas por município. Passe o mouse na célula para mediana, amplitude e município crítico.`,
|
||||
|
|
|
|||
|
|
@ -45,13 +45,13 @@ const DEFAULT_TERRITORY_LIST_SORT_DIR: TerritoryListSortDirection = 'desc'
|
|||
export const territoryListSortLabels: Record<TerritoryListSortKey, string> = {
|
||||
region: 'Território',
|
||||
municipalities: 'Municípios',
|
||||
votes2022: 'Votos 2022',
|
||||
votes2022: '2022',
|
||||
pct: '% da própria votação',
|
||||
validVotes2022: 'Válidos 2022',
|
||||
estimate2026: 'Estimativa 2026 (média)',
|
||||
estimate2026: '2026',
|
||||
coverage: 'Assessoria',
|
||||
cobertura: 'Cobertura da meta',
|
||||
captura: 'Captura (2022)',
|
||||
cobertura: 'Cobertura',
|
||||
captura: 'Captura',
|
||||
classe: 'Classe',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import {
|
|||
METROPOLITANO_SALVADOR_SUB_ROW_LABEL,
|
||||
} from '@/lib/metropolitanoTerritoryPeers'
|
||||
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
|
||||
import {
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
zeroByVoteEstimateScenario,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/lib/voteEstimate'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage'
|
||||
import { aggregateGoalCoverage } from '@/utilities/municipality/goalCoverage'
|
||||
|
|
@ -60,8 +65,13 @@ export type TerritoryMunicipalityInput = {
|
|||
kind: 'municipio' | 'zona'
|
||||
votesByYear: Record<string, number>
|
||||
validVotesByYear: Record<string, number>
|
||||
estimate2026: number
|
||||
advisorCount: number
|
||||
/** Staff totals per scenario (estimated[S] ?? pledge aggregate for S), summed per TI. */
|
||||
estimateByScenario: Record<VoteEstimateScenario, number>
|
||||
/** Any municipality in the TI carries an estimate or a declared pledge — drives "—" vs value. */
|
||||
hasEstimate: boolean
|
||||
advisorIDs: number[]
|
||||
leadershipIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
ownVotes2022: number
|
||||
fieldCeiling2022: number
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
|
|
@ -72,8 +82,12 @@ type TerritoryOverviewSubRow = {
|
|||
municipalityCount: number
|
||||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
estimate2026: number
|
||||
estimateByScenario: Record<VoteEstimateScenario, number>
|
||||
hasEstimate: boolean
|
||||
withAdvisorCount: number
|
||||
advisorIDs: number[]
|
||||
leadershipIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
pctPropriaVotacao: number
|
||||
} & TerritoryE12Rollup & {
|
||||
territorialClass?: MunicipalityTerritorialClassification | null
|
||||
|
|
@ -85,8 +99,12 @@ export type TerritoryOverviewRow = {
|
|||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
pctPropriaVotacao: number
|
||||
estimate2026: number
|
||||
estimateByScenario: Record<VoteEstimateScenario, number>
|
||||
hasEstimate: boolean
|
||||
withAdvisorCount: number
|
||||
advisorIDs: number[]
|
||||
leadershipIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
subRows?: TerritoryOverviewSubRow[]
|
||||
} & TerritoryE12Rollup & {
|
||||
territorialClass?: MunicipalityTerritorialClassification | null
|
||||
|
|
@ -115,8 +133,18 @@ type Accumulator = {
|
|||
municipalityCount: number
|
||||
votesByYear: Record<string, number>
|
||||
validVotes2022: number
|
||||
estimate2026: number
|
||||
estimateByScenario: Record<VoteEstimateScenario, number>
|
||||
hasEstimate: boolean
|
||||
withAdvisorCount: number
|
||||
advisorIDs: number[]
|
||||
leadershipIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
}
|
||||
|
||||
/** Unique-append of relation ids — sets per TI are a handful of entries. */
|
||||
const pushUniqueIds = (target: number[], ids: readonly number[]): number[] => {
|
||||
for (const id of ids) if (!target.includes(id)) target.push(id)
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -189,8 +217,12 @@ const createAccumulator = (region: string): Accumulator => ({
|
|||
municipalityCount: 0,
|
||||
votesByYear: {},
|
||||
validVotes2022: 0,
|
||||
estimate2026: 0,
|
||||
estimateByScenario: zeroByVoteEstimateScenario(),
|
||||
hasEstimate: false,
|
||||
withAdvisorCount: 0,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
})
|
||||
|
||||
const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): void => {
|
||||
|
|
@ -199,8 +231,14 @@ const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): vo
|
|||
acc.votesByYear[year] = (acc.votesByYear[year] ?? 0) + votes
|
||||
}
|
||||
acc.validVotes2022 += input.validVotesByYear[ELECTION_YEAR_2022] ?? 0
|
||||
acc.estimate2026 += input.estimate2026
|
||||
if (input.advisorCount > 0) acc.withAdvisorCount += 1
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
|
||||
acc.estimateByScenario[scenario] += input.estimateByScenario[scenario]
|
||||
}
|
||||
acc.hasEstimate = acc.hasEstimate || input.hasEstimate
|
||||
if (input.advisorIDs.length > 0) acc.withAdvisorCount += 1
|
||||
pushUniqueIds(acc.advisorIDs, input.advisorIDs)
|
||||
pushUniqueIds(acc.leadershipIDs, input.leadershipIDs)
|
||||
pushUniqueIds(acc.stateDeputyIDs, input.stateDeputyIDs)
|
||||
}
|
||||
|
||||
const pctOf = (acc: Accumulator, stateTotal2022: number): number =>
|
||||
|
|
@ -216,8 +254,12 @@ const finalizeRow = (
|
|||
votesByYear: { ...acc.votesByYear },
|
||||
validVotes2022: acc.validVotes2022,
|
||||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||||
estimate2026: acc.estimate2026,
|
||||
estimateByScenario: { ...acc.estimateByScenario },
|
||||
hasEstimate: acc.hasEstimate,
|
||||
withAdvisorCount: acc.withAdvisorCount,
|
||||
advisorIDs: [...acc.advisorIDs],
|
||||
leadershipIDs: [...acc.leadershipIDs],
|
||||
stateDeputyIDs: [...acc.stateDeputyIDs],
|
||||
...e12,
|
||||
})
|
||||
|
||||
|
|
@ -231,8 +273,12 @@ const finalizeSubRow = (
|
|||
municipalityCount: acc.municipalityCount,
|
||||
votesByYear: { ...acc.votesByYear },
|
||||
validVotes2022: acc.validVotes2022,
|
||||
estimate2026: acc.estimate2026,
|
||||
estimateByScenario: { ...acc.estimateByScenario },
|
||||
hasEstimate: acc.hasEstimate,
|
||||
withAdvisorCount: acc.withAdvisorCount,
|
||||
advisorIDs: [...acc.advisorIDs],
|
||||
leadershipIDs: [...acc.leadershipIDs],
|
||||
stateDeputyIDs: [...acc.stateDeputyIDs],
|
||||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||||
...e12,
|
||||
})
|
||||
|
|
@ -323,7 +369,7 @@ const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number
|
|||
case 'validVotes2022':
|
||||
return row.validVotes2022
|
||||
case 'estimate2026':
|
||||
return row.estimate2026
|
||||
return row.estimateByScenario.central
|
||||
case 'coverage':
|
||||
return row.municipalityCount > 0 ? row.withAdvisorCount / row.municipalityCount : 0
|
||||
case 'cobertura':
|
||||
|
|
|
|||
|
|
@ -69,9 +69,11 @@ test.describe('Seletor de colunas', () => {
|
|||
|
||||
// The cookie carries one key per list: another list is untouched. The
|
||||
// territories list is the honest neighbour here — its 27 rows come from
|
||||
// the catalog, so it never falls into an empty state.
|
||||
// the catalog, so it never falls into an empty state. B175 removed the
|
||||
// "Municípios" column (the count moved into the name), so "2022" (P0,
|
||||
// always on) is the marker that the municipios cookie did not leak.
|
||||
await page.goto('/campanha/territorios')
|
||||
await expect(page.getByRole('columnheader', { name: /Municípios/ })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /2022/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Mostrar ou ocultar colunas' })).toBeVisible()
|
||||
|
||||
await page.goto('/campanha/municipios')
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ test.describe('Territórios de Identidade', () => {
|
|||
await expect(page).toHaveURL(/\/campanha\/territorios$/)
|
||||
await expect(campaignPageChrome(page, 'Territórios de Identidade')).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click()
|
||||
// B175 — the count rides the name ("Irecê (N)") and the header is short ("2022").
|
||||
await expect(page.getByRole('link', { name: /Irecê \(\d+\)/ }).first()).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: '2022' })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: /Ordenar por 2022/ }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)
|
||||
|
||||
await page.getByRole('button', { name: 'Filtrar por Território' }).click()
|
||||
|
|
@ -26,7 +30,10 @@ test.describe('Territórios de Identidade', () => {
|
|||
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 page
|
||||
.getByRole('link', { name: /Irecê \(\d+\)/ })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/)
|
||||
})
|
||||
|
||||
|
|
@ -48,6 +55,41 @@ test.describe('Territórios de Identidade', () => {
|
|||
await expect(page.locator('#ti-velho-chico')).toBeVisible()
|
||||
})
|
||||
|
||||
test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Rede Larga'),
|
||||
})
|
||||
const password = coordinator.password
|
||||
const email = coordinator.email!
|
||||
|
||||
await page.setViewportSize({ width: 2200, height: 1000 })
|
||||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/territorios')
|
||||
|
||||
// B175 — the responsive rungs are all visible at a wide panel (P0 + Captura
|
||||
// + network + Classe + Assessoria). Cobertura is P3 AND hidden in the picker.
|
||||
// Sortable headers are announced "Ordenar por <label> (…)" — match by prefix;
|
||||
// the read-only network headers are plain, so match them exactly (never by
|
||||
// substring, since "Assessor" is a prefix of "Assessoria").
|
||||
for (const header of ['2022', 'Captura', '2026', 'Classe', 'Assessoria']) {
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: new RegExp(`^Ordenar por ${header}`) }),
|
||||
`header ${header}`,
|
||||
).toBeVisible()
|
||||
}
|
||||
for (const header of ['Assessor', 'Liderança', 'Dobradinha']) {
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: header, exact: true }),
|
||||
`header ${header}`,
|
||||
).toBeVisible()
|
||||
}
|
||||
await expect(page.getByRole('columnheader', { name: 'Cobertura' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('leader cannot open the territories page', async ({ campaign, page }) => {
|
||||
const { fixtures } = campaign
|
||||
const phone = fixtures.phone()
|
||||
|
|
|
|||
|
|
@ -81,6 +81,17 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Cobertura by default on territorios (B175), like goalCoverage on municipios', () => {
|
||||
expect(resolveCampaignColumnVisibility('territorios', {})).toEqual({
|
||||
listId: 'territorios',
|
||||
hiddenColumnIds: ['cobertura'],
|
||||
})
|
||||
expect(resolveCampaignColumnVisibility('territorios', { territorios: [] })).toEqual({
|
||||
listId: 'territorios',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleHiddenColumn', () => {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,11 @@ const input = (
|
|||
kind: 'municipio',
|
||||
votesByYear: { 2022: 0 },
|
||||
validVotesByYear: { 2022: 0 },
|
||||
estimate2026: 0,
|
||||
advisorCount: 0,
|
||||
estimateByScenario: { pessimistic: 0, central: 0, optimistic: 0 },
|
||||
hasEstimate: false,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
stateDeputyIDs: [],
|
||||
ownVotes2022: 0,
|
||||
fieldCeiling2022: 0,
|
||||
goalCoverage: emptyGoalCoverage,
|
||||
|
|
@ -45,8 +48,11 @@ const fixture: TerritoryMunicipalityInput[] = [
|
|||
city: 'Ibipeba',
|
||||
votesByYear: { 2014: 10, 2018: 20, 2022: 30 },
|
||||
validVotesByYear: { 2022: 1000 },
|
||||
estimate2026: 40,
|
||||
advisorCount: 1,
|
||||
estimateByScenario: { pessimistic: 35, central: 40, optimistic: 45 },
|
||||
hasEstimate: true,
|
||||
advisorIDs: [7],
|
||||
leadershipIDs: [11, 12],
|
||||
stateDeputyIDs: [1, 2],
|
||||
ownVotes2022: 30,
|
||||
fieldCeiling2022: 300,
|
||||
goalCoverage: { goal: 100, committed: 20, coverageRatio: 0.2, deficit: 80 },
|
||||
|
|
@ -57,8 +63,10 @@ const fixture: TerritoryMunicipalityInput[] = [
|
|||
city: 'Irecê',
|
||||
votesByYear: { 2014: 5, 2018: 15, 2022: 25 },
|
||||
validVotesByYear: { 2022: 2000 },
|
||||
estimate2026: 35,
|
||||
advisorCount: 0,
|
||||
estimateByScenario: { pessimistic: 33, central: 35, optimistic: 37 },
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [11],
|
||||
stateDeputyIDs: [2, 3],
|
||||
ownVotes2022: 25,
|
||||
fieldCeiling2022: 100,
|
||||
goalCoverage: { goal: 50, committed: 10, coverageRatio: 0.2, deficit: 40 },
|
||||
|
|
@ -70,8 +78,9 @@ const fixture: TerritoryMunicipalityInput[] = [
|
|||
city: 'Barra',
|
||||
votesByYear: { 2014: 100, 2018: 110, 2022: 120 },
|
||||
validVotesByYear: { 2022: 5000 },
|
||||
estimate2026: 150,
|
||||
advisorCount: 2,
|
||||
estimateByScenario: { pessimistic: 140, central: 150, optimistic: 160 },
|
||||
hasEstimate: true,
|
||||
advisorIDs: [7, 8],
|
||||
ownVotes2022: 120,
|
||||
fieldCeiling2022: 1200,
|
||||
}),
|
||||
|
|
@ -83,8 +92,10 @@ const fixture: TerritoryMunicipalityInput[] = [
|
|||
kind: 'zona',
|
||||
votesByYear: { 2014: 1000, 2018: 1100, 2022: 1200 },
|
||||
validVotesByYear: { 2022: 40000 },
|
||||
estimate2026: 1300,
|
||||
advisorCount: 1,
|
||||
estimateByScenario: { pessimistic: 1200, central: 1300, optimistic: 1400 },
|
||||
hasEstimate: true,
|
||||
advisorIDs: [9],
|
||||
stateDeputyIDs: [5],
|
||||
ownVotes2022: 1200,
|
||||
fieldCeiling2022: 12000,
|
||||
}),
|
||||
|
|
@ -94,8 +105,8 @@ const fixture: TerritoryMunicipalityInput[] = [
|
|||
city: 'Camaçari',
|
||||
votesByYear: { 2014: 200, 2018: 210, 2022: 220 },
|
||||
validVotesByYear: { 2022: 8000 },
|
||||
estimate2026: 250,
|
||||
advisorCount: 0,
|
||||
estimateByScenario: { pessimistic: 240, central: 250, optimistic: 260 },
|
||||
advisorIDs: [10],
|
||||
ownVotes2022: 220,
|
||||
fieldCeiling2022: 2200,
|
||||
}),
|
||||
|
|
@ -117,9 +128,35 @@ describe('computeTerritoryRollup', () => {
|
|||
expect(irece.municipalityCount).toBe(2)
|
||||
})
|
||||
|
||||
it('sums 2026 estimates', () => {
|
||||
it('sums 2026 estimates per scenario', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
expect(irece.estimate2026).toBe(75)
|
||||
expect(irece.estimateByScenario).toEqual({
|
||||
pessimistic: 68,
|
||||
central: 75,
|
||||
optimistic: 82,
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a territory as having an estimate when any municipality does', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
const metro = rows.find((row) => row.region === 'Metropolitano de Salvador')!
|
||||
expect(irece.hasEstimate).toBe(true)
|
||||
expect(metro.hasEstimate).toBe(true)
|
||||
const allEmpty = computeTerritoryRollup([
|
||||
input({ slug: 'x', region: 'Velho Chico', city: 'X' }),
|
||||
])
|
||||
expect(allEmpty[0].hasEstimate).toBe(false)
|
||||
})
|
||||
|
||||
it('unions advisors, leaderships and state deputies per territory (dedup)', () => {
|
||||
const irece = rows.find((row) => row.region === 'Irecê')!
|
||||
const velhoChico = rows.find((row) => row.region === 'Velho Chico')!
|
||||
const metro = rows.find((row) => row.region === 'Metropolitano de Salvador')!
|
||||
expect(irece.advisorIDs).toEqual([7])
|
||||
expect(irece.leadershipIDs).toEqual([11, 12])
|
||||
expect(irece.stateDeputyIDs).toEqual([1, 2, 3])
|
||||
expect(velhoChico.advisorIDs).toEqual([7, 8])
|
||||
expect(metro.advisorIDs).toEqual([9, 10])
|
||||
})
|
||||
|
||||
it('counts municipalities with advisor', () => {
|
||||
|
|
@ -144,7 +181,12 @@ describe('computeTerritoryRollup', () => {
|
|||
expect(demais.municipalityCount).toBe(1)
|
||||
// Sub-rows sum to the Metropolitano total.
|
||||
expect(salvador.votesByYear[2022] + demais.votesByYear[2022]).toBe(metro.votesByYear[2022])
|
||||
expect(salvador.estimate2026 + demais.estimate2026).toBe(metro.estimate2026)
|
||||
expect(salvador.estimateByScenario.central + demais.estimateByScenario.central).toBe(
|
||||
metro.estimateByScenario.central,
|
||||
)
|
||||
// Sub-rows carry their own network sets (Salvador zones × Demais RMS).
|
||||
expect(salvador.advisorIDs).toEqual([9])
|
||||
expect(demais.advisorIDs).toEqual([10])
|
||||
})
|
||||
|
||||
it('returns zero % when state total is zero', () => {
|
||||
|
|
@ -209,12 +251,14 @@ describe('filterTerritoryRows', () => {
|
|||
})
|
||||
|
||||
it('treats com assessor as complete coverage and sem assessor as any gap', () => {
|
||||
// Velho Chico (1/1) and Metropolitano (Salvador + Camaçari both with advisor) are complete.
|
||||
expect(
|
||||
filterTerritoryRows(rows, { coverage: 'com_assessor' }).map((row) => row.region),
|
||||
).toEqual(['Velho Chico'])
|
||||
).toEqual(['Velho Chico', 'Metropolitano de Salvador'])
|
||||
// Irecê has only ibipeba with an advisor → a gap.
|
||||
expect(
|
||||
filterTerritoryRows(rows, { coverage: 'sem_assessor' }).map((row) => row.region),
|
||||
).toEqual(['Irecê', 'Metropolitano de Salvador'])
|
||||
).toEqual(['Irecê'])
|
||||
})
|
||||
|
||||
it('keeps Metropolitano sub-rows attached when the parent matches', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue