feat(campaignTerritories): repintar a lista de Territórios no padrão da lista de Municípios (B175)

- Colunas responsivas ao painel via @container/territory-list (P0→P3), sem scroll horizontal
- Contagem de municípios no nome ("Irecê (20)"); % e votos absorvidos na célula 2022 (hover = válidos)
- Célula 2026 no detalhe de Municípios (valor central + %, faixa de cenários no hover, leitura)
- Colunas de rede só leitura: Assessor, Liderança, Dobradinha (para todo o staff)
- Cobertura oculta por padrão no seletor; labels curtos (2022/2026/Captura/Cobertura)
- loadTerritoryOverview devolve { rows, references }; rollup puro com soma por cenário, hasEstimate e uniões de rede
This commit is contained in:
Francisco Solla 2026-08-08 23:57:59 -03:00
parent 2ba0cebf6f
commit e75bb1c2f3
13 changed files with 794 additions and 294 deletions

View 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 (`hidden @min-[Xrem]/territory-list:table-cell`):
- `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)

View file

@ -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

View file

@ -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>
)
}

View file

@ -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 },
]

View file

@ -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 =>

View file

@ -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)

View file

@ -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,
},
}
},
)

View file

@ -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.`,

View file

@ -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',
}

View file

@ -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':

View file

@ -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()

View file

@ -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', () => {

View file

@ -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', () => {