diff --git a/docs/plans/municipio-v2-rede-impl.md b/docs/plans/municipio-v2-rede-impl.md
new file mode 100644
index 00000000..d9575783
--- /dev/null
+++ b/docs/plans/municipio-v2-rede-impl.md
@@ -0,0 +1,89 @@
+# Impl: Município v2 — rede (lista edit-in-place)
+
+Status: aprovado
+Atualizado em: 2026-08-03
+Issue: #332
+Intenção: docs/plans/municipio-v2-rede.md
+Appetite restante: ~1 dia eng (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** rede na dobra principal da v2 — lista densa (tabela desktop / lista mobile) com top-N lideranças, declarado e estimado editáveis no lugar (auto-save B9), caminho para ver todas e criar liderança; leader não vê estimado.
+- **O que NÃO negociar:** assimetria estimado (staff-only); leader lockdown na página; não cards empilhados; não spreadsheet de colunas da ficha global; não substituir ficha completa (link ao detalhe).
+- **O que reavaliar:** reusar `DeclareVotesForm`/`PledgeEstimateForm` (rejeitados — têm botão Salvar); twin de loaders vs composer fino; N do top-N.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ Page["municipio/[slug]/v2/page.tsx"]
+ Loader["loadMunicipalityV2NetworkData"]
+ Panel["MunicipalityV2NetworkSection"]
+ List["MunicipalityV2NetworkList"]
+ Declared["MunicipalityV2DeclaredVotesCell"]
+ Estimated["MunicipalityV2EstimatedVotesCell"]
+ DeclAPI["POST municipios/pledge-declared-votes"]
+ EstAPI["POST municipios/pledge-estimated-votes"]
+ Page --> Loader --> Panel --> List
+ List --> Declared --> DeclAPI
+ List --> Estimated --> EstAPI
+```
+
+**Opções consideradas:**
+
+| | A — Reusar panels atuais (cards + forms) | B — Lista densa + B9 cell autosave + JSON routes | C — CampaignTable completo com colunas B17 |
+| --- | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
+| | Rápido mas cards + Salvar | Alinha B9 e aceite | Overkill sem picker de colunas |
+
+**Recomendação:** B — lista densa com `CampaignCellEditOverlay` + `useCampaignCellAutosave`; writes via `declareVotes`/`estimateVotes` existentes; revalidate v2 via `revalidateMunicipalityListPaths`.
+
+**Rejeitadas:** A (cards + botão Salvar violam aceite). C (B17 não pedido; 4 colunas fixas).
+
+### Decisões de engenharia
+
+1. **Top-N = 8** — mesmo cap do dossiê (`DOSSIER_LEADERSHIP_LIMIT`). Ordenação: `effective(central)` desc, depois `declared` desc (assumido na intenção).
+2. **Merge leadership × pledge** em pure `buildMunicipalityV2NetworkRows` — liderança sem pledge aparece com declarado/estimado vazios; estimado só editável quando existe `pledgeID`.
+3. **JSON routes** em `municipios/pledge-declared-votes` e `municipios/pledge-estimated-votes` (paridade `expected-votes`); `safeMessages` de `votePledge` schemas.
+4. **Estimado na célula** — trigger mostra média (cenário central); popover/sheet com `VoteEstimateScenarioInputs` compact (precedente `MunicipalityListExpectedVotesControl`).
+5. **Declarado na célula** — popover/sheet com input numérico; autosave debounce 600ms + flush no close.
+6. **CTAs** — "Ver todas" → `?tab=leaderships`; "Nova liderança" → `/campanha/liderancas/nova?municipality=` (textual, não FAB — B151).
+
+### Componentes / mudanças
+
+- **`loadMunicipalityV2NetworkData`** (`utilities/municipality/municipalityV2NetworkData.ts`): composer sobre `loadMunicipalityLeaderships` + `loadMunicipalityPledges`.
+- **`municipalityV2NetworkView.ts`**: tipos + sort/merge puros (unit-tested).
+- **`MunicipalityV2NetworkSection` + `MunicipalityV2NetworkList` + células** (`components/campaign/municipality/`).
+- **Routes:** `pledge-declared-votes/route.ts`, `pledge-estimated-votes/route.ts` + `types.ts`.
+- **`pledgeFormActions.ts`:** revalidate via `revalidateMunicipalityListPaths` (inclui v2).
+- **Migration:** nenhuma.
+- **Access:** `declareVotes`/`estimateVotes` existentes; página `noLeader`.
+- **UI:** Impeccable B — lista densa; shells B9; `data-theme='campaign'`.
+
+### Dados → forma
+
+- Tabela 4 colunas (nome · status · declarado · estimado); mobile = mesma informação em linhas densas, não cards empilhados por pessoa com forms longos.
+
+## Fases verificáveis
+
+1. **Server** — view puro + loader + JSON routes + revalidate paths.
+2. **UI** — section na página v2 + células autosave + CTAs.
+3. **Gates** — unit sort/merge; `pnpm gate:fast`; `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- FAB B151; conta B148; agora B150; cutover B152.
+- Edição de status de apoio na rede (fora das 4 colunas).
+- Nota de estimativa na lista (ficha completa / popover longo).
+
+## Riscos e mitigação
+
+- **Conta não atualiza após edit:** `revalidateMunicipalityListPaths` busta página v2; aceite permite conta refresh posterior (B148).
+- **Pledge inexistente:** declarado cria via `declareVotes`; estimado disabled até existir pledge.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto
+- [ ] Invariantes AGENTS/engineering-standards
+- [ ] Unit tests em `municipalityV2NetworkView`
+
+Self-score decision-quality: 4/5
diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx
index 070b1e2e..5a00d136 100644
--- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx
@@ -3,12 +3,14 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions'
+import { MunicipalityV2NetworkSection } from '@/components/campaign/municipality/MunicipalityV2NetworkSection'
import { MunicipalityV2StatusStrip } from '@/components/campaign/municipality/MunicipalityV2StatusStrip'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData'
+import { loadMunicipalityV2NetworkData } from '@/utilities/municipality/municipalityV2NetworkData'
import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData'
type MunicipalityV2PageProps = {
@@ -30,8 +32,14 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
])
let status
+ let network
try {
- ;({ status } = await loadMunicipalityV2StatusData(payload, user, slug))
+ const loaded = await Promise.all([
+ loadMunicipalityV2StatusData(payload, user, slug),
+ loadMunicipalityV2NetworkData(payload, user, slug),
+ ])
+ ;({ status } = loaded[0])
+ network = loaded[1]
} catch (error) {
if (error instanceof MunicipalityNotFoundError) notFound()
throw error
@@ -52,12 +60,8 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
Em breve nesta visão.
-
-
- Rede
-
- Em breve nesta visão.
-
+
+
Agora
diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
index db24118f..cfa7ebb8 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
+++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/pledgeFormActions.ts
@@ -1,8 +1,6 @@
'use server'
-import { revalidatePath } from 'next/cache'
-
-import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
+import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
import { declareVotes, estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge'
import {
@@ -11,6 +9,7 @@ import {
requiredRelationshipFormValue,
voteEstimateScenarioFromForm,
} from '@/lib/formData'
+import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
import {
VOTE_PLEDGE_DECLARE_SAFE_MESSAGES,
VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE,
@@ -36,7 +35,7 @@ export const declareVotesFormAction = async (
})
await declareVotes({ municipality, leadership, declaredVotes })
- revalidatePath('/campanha/municipios/[slug]', 'page')
+ revalidateMunicipalityListPaths({ scope: 'detail' })
return { message: 'Declaração de votos registrada.' }
},
safeMessages: declareSafeMessages,
@@ -59,7 +58,7 @@ export const estimateVotesFormAction = async (
estimatedVotes,
estimateNote,
})
- revalidatePath('/campanha/municipios/[slug]', 'page')
+ revalidateMunicipalityListPaths({ scope: 'detail' })
return { message: 'Estimativa registrada.' }
},
safeMessages: [VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE],
diff --git a/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts
new file mode 100644
index 00000000..084bc521
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { declareVotes } from '@/app/(campaign)/campanha/actions/votePledge'
+import { MAX_VOTE_COUNT, positiveRelationshipId } from '@/lib/schemas/primitives'
+import {
+ VOTE_PLEDGE_DECLARE_SAFE_MESSAGES,
+ VOTE_PLEDGE_LEADERSHIP_REQUIRED_MESSAGE,
+ VOTE_PLEDGE_MUNICIPALITY_NOT_LINKED_MESSAGE,
+} from '@/lib/schemas/votePledge'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
+
+import type { MunicipalityPledgeDeclaredVotesResponse } from './types'
+
+export type { MunicipalityPledgeDeclaredVotesResponse } from './types'
+
+export const dynamic = 'force-dynamic'
+
+const bodySchema = z.object({
+ municipalityId: positiveRelationshipId,
+ leadershipId: positiveRelationshipId,
+ declaredVotes: z.number().int().min(0).max(MAX_VOTE_COUNT),
+})
+
+export const POST = campaignJsonMutationRoute(
+ {
+ bodySchema,
+ safeMessages: [
+ ...VOTE_PLEDGE_DECLARE_SAFE_MESSAGES,
+ VOTE_PLEDGE_LEADERSHIP_REQUIRED_MESSAGE,
+ VOTE_PLEDGE_MUNICIPALITY_NOT_LINKED_MESSAGE,
+ ],
+ genericMessage:
+ 'Não foi possível salvar os votos declarados. Verifique seu acesso e tente novamente.',
+ },
+ async ({ municipalityId, leadershipId, declaredVotes }) => {
+ const pledge = await declareVotes({
+ municipality: municipalityId,
+ leadership: leadershipId,
+ declaredVotes,
+ })
+
+ revalidateMunicipalityListPaths({ scope: 'detail' })
+
+ return NextResponse.json({
+ status: 'success',
+ message: 'Votos declarados atualizados.',
+ savedDeclaredVotes: pledge.declaredVotes,
+ pledgeId: pledge.id,
+ })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts
new file mode 100644
index 00000000..ea5bdcdd
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts
@@ -0,0 +1,6 @@
+export const MUNICIPALITY_PLEDGE_DECLARED_VOTES_ENDPOINT =
+ '/campanha/municipios/pledge-declared-votes'
+
+export type MunicipalityPledgeDeclaredVotesResponse =
+ | { status: 'success'; message: string; savedDeclaredVotes: number; pledgeId: number }
+ | { status: 'error'; message: string }
diff --git a/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts
new file mode 100644
index 00000000..5c6d412c
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts
@@ -0,0 +1,46 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+import { estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge'
+import { positiveRelationshipId } from '@/lib/schemas/primitives'
+import {
+ VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE,
+ voteEstimateScenarioFieldsSchema,
+} from '@/lib/schemas/votePledge'
+import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
+
+import type { MunicipalityPledgeEstimatedVotesResponse } from './types'
+
+export type { MunicipalityPledgeEstimatedVotesResponse } from './types'
+
+export const dynamic = 'force-dynamic'
+
+const bodySchema = z.object({
+ pledgeId: positiveRelationshipId,
+ estimatedVotes: voteEstimateScenarioFieldsSchema,
+})
+
+export const POST = campaignJsonMutationRoute(
+ {
+ bodySchema,
+ safeMessages: [VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE],
+ genericMessage: 'Não foi possível salvar a estimativa. Verifique seu acesso e tente novamente.',
+ },
+ async ({ pledgeId, estimatedVotes }) => {
+ const pledge = await estimateVotes({
+ pledge: pledgeId,
+ estimatedVotes,
+ estimateNote: null,
+ })
+
+ revalidateMunicipalityListPaths({ scope: 'detail' })
+
+ return NextResponse.json({
+ status: 'success',
+ message: 'Estimativa atualizada.',
+ savedEstimatedVotes: toVoteEstimateScenarioViewModel(pledge.estimatedVotes),
+ })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/types.ts b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/types.ts
new file mode 100644
index 00000000..89d61c2a
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/types.ts
@@ -0,0 +1,12 @@
+import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
+
+export const MUNICIPALITY_PLEDGE_ESTIMATED_VOTES_ENDPOINT =
+ '/campanha/municipios/pledge-estimated-votes'
+
+export type MunicipalityPledgeEstimatedVotesResponse =
+ | {
+ status: 'success'
+ message: string
+ savedEstimatedVotes: VoteEstimateScenarioViewModel
+ }
+ | { status: 'error'; message: string }
diff --git a/src/components/campaign/municipality/MunicipalityV2DeclaredVotesCell.tsx b/src/components/campaign/municipality/MunicipalityV2DeclaredVotesCell.tsx
new file mode 100644
index 00000000..85ec21ea
--- /dev/null
+++ b/src/components/campaign/municipality/MunicipalityV2DeclaredVotesCell.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import {
+ MUNICIPALITY_PLEDGE_DECLARED_VOTES_ENDPOINT,
+ type MunicipalityPledgeDeclaredVotesResponse,
+} from '@/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types'
+import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
+import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
+import { Alert, AlertDescription } from '@/components/ui/Alert'
+import { Field, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { Spinner } from '@/components/ui/Spinner'
+import { formatElectionNumber } from '@/lib/electionFormat'
+
+const AUTOSAVE_MS = 600
+const SAVE_ERROR_MESSAGE =
+ 'Não foi possível salvar os votos declarados. Verifique seu acesso e tente novamente.'
+
+type MunicipalityV2DeclaredVotesCellProps = {
+ municipalityID: number
+ leadershipID: number
+ leadershipName: string
+ declaredVotes: number | null
+ variant: 'popover' | 'sheet'
+}
+
+const declaredVotesEqual = (left: number | null, right: number | null): boolean => left === right
+
+export const MunicipalityV2DeclaredVotesCell = ({
+ municipalityID,
+ leadershipID,
+ leadershipName,
+ declaredVotes,
+ variant,
+}: MunicipalityV2DeclaredVotesCellProps) => {
+ const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
+ useCampaignCellAutosave({
+ value: declaredVotes,
+ equals: declaredVotesEqual,
+ endpoint: MUNICIPALITY_PLEDGE_DECLARED_VOTES_ENDPOINT,
+ buildBody: (votes) => ({
+ municipalityId: municipalityID,
+ leadershipId: leadershipID,
+ declaredVotes: votes ?? 0,
+ }),
+ readSaved: (payload) => payload.savedDeclaredVotes,
+ errorMessage: SAVE_ERROR_MESSAGE,
+ pendingMessage: 'Salvando votos declarados.',
+ })
+
+ const triggerLabel = [
+ 'Editar votos declarados de ',
+ leadershipName,
+ ' — ',
+ value == null ? 'sem declaração' : formatElectionNumber(value),
+ ].join('')
+
+ const display =
+ value == null ? (
+ —
+ ) : (
+ {formatElectionNumber(value)}
+ )
+
+ const handleInputChange = (raw: string) => {
+ if (raw.trim() === '') {
+ change(null, AUTOSAVE_MS)
+ return
+ }
+ const parsed = Number.parseInt(raw, 10)
+ if (Number.isNaN(parsed)) return
+ change(parsed, AUTOSAVE_MS)
+ }
+
+ return (
+
+
+ {isPending ? (
+
+ ) : null}
+
+
+ Votos declarados
+
+ handleInputChange(event.target.value)}
+ className="min-h-11 tabular-nums"
+ />
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx b/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx
new file mode 100644
index 00000000..177c2ba5
--- /dev/null
+++ b/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { useState } from 'react'
+
+import {
+ MUNICIPALITY_PLEDGE_ESTIMATED_VOTES_ENDPOINT,
+ type MunicipalityPledgeEstimatedVotesResponse,
+} from '@/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/types'
+import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
+import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave'
+import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
+import { Alert, AlertDescription } from '@/components/ui/Alert'
+import { Spinner } from '@/components/ui/Spinner'
+import { formatElectionNumber } from '@/lib/electionFormat'
+import {
+ DEFAULT_VOTE_ESTIMATE_SCENARIO,
+ effectivePledgeVotesForScenario,
+ getVoteEstimateForScenario,
+ voteEstimateScenarioLabels,
+ voteEstimatesEqual,
+ type VoteEstimateScenario,
+ type VoteEstimateScenarioViewModel,
+} from '@/lib/voteEstimate'
+
+const AUTOSAVE_MS = 600
+const SAVE_ERROR_MESSAGE =
+ 'Não foi possível salvar a estimativa. Verifique seu acesso e tente novamente.'
+
+type MunicipalityV2EstimatedVotesCellProps = {
+ pledgeID: number | null
+ leadershipName: string
+ declaredVotes: number | null
+ estimatedVotes: VoteEstimateScenarioViewModel
+ variant: 'popover' | 'sheet'
+}
+
+type EditableProps = Omit & {
+ pledgeID: number
+}
+
+const MunicipalityV2EstimatedVotesCellEditable = ({
+ pledgeID,
+ leadershipName,
+ declaredVotes,
+ estimatedVotes,
+ variant,
+}: EditableProps) => {
+ const declared = declaredVotes ?? 0
+ const centralEstimate = getVoteEstimateForScenario(estimatedVotes, DEFAULT_VOTE_ESTIMATE_SCENARIO)
+ const effectiveCentral = effectivePledgeVotesForScenario(
+ declared,
+ estimatedVotes,
+ DEFAULT_VOTE_ESTIMATE_SCENARIO,
+ )
+
+ const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
+ useCampaignCellAutosave<
+ VoteEstimateScenarioViewModel,
+ MunicipalityPledgeEstimatedVotesResponse
+ >({
+ value: estimatedVotes,
+ equals: voteEstimatesEqual,
+ endpoint: MUNICIPALITY_PLEDGE_ESTIMATED_VOTES_ENDPOINT,
+ buildBody: (votes) => ({ pledgeId: pledgeID, estimatedVotes: votes }),
+ readSaved: (payload) => payload.savedEstimatedVotes,
+ errorMessage: SAVE_ERROR_MESSAGE,
+ pendingMessage: 'Salvando estimativa.',
+ })
+
+ const [focusedScenario, setFocusedScenario] = useState('central')
+
+ const triggerLabel = [
+ 'Editar estimativa de ',
+ leadershipName,
+ ' — ',
+ voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO],
+ ': ',
+ centralEstimate == null
+ ? formatElectionNumber(effectiveCentral)
+ : formatElectionNumber(centralEstimate),
+ ].join('')
+
+ const display =
+ centralEstimate == null ? (
+
+ {formatElectionNumber(effectiveCentral)}
+
+ ) : (
+ {formatElectionNumber(centralEstimate)}
+ )
+
+ return (
+
+
+ {isPending ? (
+
+ ) : null}
+
change(values, AUTOSAVE_MS)}
+ />
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+ )
+}
+
+export const MunicipalityV2EstimatedVotesCell = ({
+ pledgeID,
+ leadershipName,
+ declaredVotes,
+ estimatedVotes,
+ variant,
+}: MunicipalityV2EstimatedVotesCellProps) => {
+ if (pledgeID === null) {
+ return —
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/municipality/MunicipalityV2NetworkList.tsx b/src/components/campaign/municipality/MunicipalityV2NetworkList.tsx
new file mode 100644
index 00000000..1621d419
--- /dev/null
+++ b/src/components/campaign/municipality/MunicipalityV2NetworkList.tsx
@@ -0,0 +1,136 @@
+'use client'
+
+import Link from 'next/link'
+
+import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
+import { MunicipalityV2DeclaredVotesCell } from '@/components/campaign/municipality/MunicipalityV2DeclaredVotesCell'
+import { MunicipalityV2EstimatedVotesCell } from '@/components/campaign/municipality/MunicipalityV2EstimatedVotesCell'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/Table'
+import { formatElectionNumber } from '@/lib/electionFormat'
+import type { MunicipalityV2NetworkViewModel } from '@/utilities/municipality/municipalityV2NetworkView'
+
+type MunicipalityV2NetworkListProps = {
+ network: MunicipalityV2NetworkViewModel
+}
+
+export const MunicipalityV2NetworkList = ({ network }: MunicipalityV2NetworkListProps) => {
+ if (network.rows.length === 0) {
+ return (
+
+ Nenhuma liderança vinculada a este município ainda.
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ Nome
+ Status
+ Declarado
+ Estimado
+
+
+
+ {network.rows.map((row) => (
+
+
+
+ {row.name}
+
+
+
+ {row.supportStatus ? : null}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {network.totalCount > network.rows.length ? (
+
+ Mostrando {formatElectionNumber(network.rows.length)} de{' '}
+ {formatElectionNumber(network.totalCount)} lideranças — use Ver todas para o cadastro
+ completo.
+
+ ) : null}
+ >
+ )
+}
diff --git a/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx b/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx
new file mode 100644
index 00000000..7ee7d87f
--- /dev/null
+++ b/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx
@@ -0,0 +1,47 @@
+import { PlusIcon } from 'lucide-react'
+import Link from 'next/link'
+
+import { MunicipalityV2NetworkList } from '@/components/campaign/municipality/MunicipalityV2NetworkList'
+import { Button } from '@/components/ui/button'
+import { formatElectionNumber } from '@/lib/electionFormat'
+import type { MunicipalityV2NetworkViewModel } from '@/utilities/municipality/municipalityV2NetworkView'
+
+type MunicipalityV2NetworkSectionProps = {
+ network: MunicipalityV2NetworkViewModel
+}
+
+export const MunicipalityV2NetworkSection = ({ network }: MunicipalityV2NetworkSectionProps) => {
+ const seeAllHref = `/campanha/municipios/${network.slug}?tab=leaderships`
+ const createHref = `/campanha/liderancas/nova?municipality=${network.municipalityID}`
+
+ return (
+
+
+
+
+ Rede
+ {network.totalCount > 0 ? ` (${formatElectionNumber(network.totalCount)})` : ''}
+
+
+ Quem segura o município — ajuste declarado e estimado sem trocar de aba.
+
+
+
+ {network.totalCount > network.rows.length ? (
+
+ Ver todas
+
+ ) : null}
+
+
+
+ Nova liderança
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/utilities/municipality/municipalityV2NetworkData.ts b/src/utilities/municipality/municipalityV2NetworkData.ts
new file mode 100644
index 00000000..20cfbf27
--- /dev/null
+++ b/src/utilities/municipality/municipalityV2NetworkData.ts
@@ -0,0 +1,47 @@
+import 'server-only'
+
+import type { Payload } from 'payload'
+
+import type { CampaignUser } from '@/payload-types'
+import { loadMunicipalityLeaderships } from '@/utilities/leadership/leadershipData'
+import { resolveAccessibleMunicipalityContext } from '@/utilities/municipality/municipalityPageData'
+import {
+ buildMunicipalityV2NetworkRows,
+ type MunicipalityV2NetworkViewModel,
+} from '@/utilities/municipality/municipalityV2NetworkView'
+import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
+
+export type { MunicipalityV2NetworkViewModel }
+
+export const loadMunicipalityV2NetworkData = async (
+ payload: Payload,
+ user: CampaignUser,
+ municipalitySlug: string,
+): Promise => {
+ const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug)
+ const [leaderships, pledges] = await Promise.all([
+ loadMunicipalityLeaderships(payload, user, context.id),
+ loadMunicipalityPledges(payload, user, context.id),
+ ])
+
+ const { rows, totalCount } = buildMunicipalityV2NetworkRows(
+ leaderships.map((leadership) => ({
+ id: leadership.id,
+ name: leadership.name,
+ supportStatus: leadership.supportStatus,
+ })),
+ pledges.map((pledge) => ({
+ id: pledge.id,
+ leadershipID: pledge.leadershipID,
+ declaredVotes: pledge.declaredVotes,
+ estimatedVotes: pledge.estimatedVotes,
+ })),
+ )
+
+ return {
+ municipalityID: context.id,
+ slug: context.slug,
+ rows,
+ totalCount,
+ }
+}
diff --git a/src/utilities/municipality/municipalityV2NetworkView.ts b/src/utilities/municipality/municipalityV2NetworkView.ts
new file mode 100644
index 00000000..03795214
--- /dev/null
+++ b/src/utilities/municipality/municipalityV2NetworkView.ts
@@ -0,0 +1,86 @@
+/**
+ * Pure view helpers + row model for municipality v2 network (B149).
+ */
+import type { SupportStatus } from '@/lib/schemas/leadership'
+import {
+ DEFAULT_VOTE_ESTIMATE_SCENARIO,
+ effectivePledgeVotesForScenario,
+ toVoteEstimateScenarioViewModel,
+ type VoteEstimateScenarioViewModel,
+} from '@/lib/voteEstimate'
+
+/** Same cap as the dossier leadership preview — dense, not exhaustive. */
+export const MUNICIPALITY_V2_NETWORK_LIMIT = 8
+
+export type MunicipalityV2NetworkRow = {
+ leadershipID: number
+ name: string
+ supportStatus: SupportStatus | null
+ pledgeID: number | null
+ declaredVotes: number | null
+ estimatedVotes: VoteEstimateScenarioViewModel
+}
+
+export type MunicipalityV2NetworkViewModel = {
+ municipalityID: number
+ slug: string
+ rows: MunicipalityV2NetworkRow[]
+ totalCount: number
+}
+
+/** Minimal leadership input for merge — keeps this module client-safe. */
+export type MunicipalityV2NetworkLeadershipInput = {
+ id: number
+ name: string
+ supportStatus: SupportStatus | null
+}
+
+/** Minimal pledge input for merge — keeps this module client-safe. */
+export type MunicipalityV2NetworkPledgeInput = {
+ id: number
+ leadershipID: number
+ declaredVotes: number
+ estimatedVotes: VoteEstimateScenarioViewModel
+}
+
+const networkRowSortScore = (row: MunicipalityV2NetworkRow): number => {
+ const declared = row.declaredVotes ?? 0
+ return effectivePledgeVotesForScenario(
+ declared,
+ row.estimatedVotes,
+ DEFAULT_VOTE_ESTIMATE_SCENARIO,
+ )
+}
+
+export const buildMunicipalityV2NetworkRows = (
+ leaderships: MunicipalityV2NetworkLeadershipInput[],
+ pledges: MunicipalityV2NetworkPledgeInput[],
+ limit = MUNICIPALITY_V2_NETWORK_LIMIT,
+): { rows: MunicipalityV2NetworkRow[]; totalCount: number } => {
+ const pledgeByLeadership = new Map(pledges.map((pledge) => [pledge.leadershipID, pledge]))
+
+ const merged: MunicipalityV2NetworkRow[] = leaderships.map((leadership) => {
+ const pledge = pledgeByLeadership.get(leadership.id)
+ return {
+ leadershipID: leadership.id,
+ name: leadership.name,
+ supportStatus: leadership.supportStatus,
+ pledgeID: pledge?.id ?? null,
+ declaredVotes: pledge?.declaredVotes ?? null,
+ estimatedVotes: pledge?.estimatedVotes ?? toVoteEstimateScenarioViewModel(null),
+ }
+ })
+
+ const sorted = [...merged].sort((left, right) => {
+ const scoreDelta = networkRowSortScore(right) - networkRowSortScore(left)
+ if (scoreDelta !== 0) return scoreDelta
+ const declaredDelta = (right.declaredVotes ?? 0) - (left.declaredVotes ?? 0)
+ if (declaredDelta !== 0) return declaredDelta
+ return left.name.localeCompare(right.name, 'pt-BR')
+ })
+
+ return {
+ rows: sorted.slice(0, limit),
+ totalCount: leaderships.length,
+ }
+}
diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts
index b5c7194c..f81eef5c 100644
--- a/tests/e2e/setup.e2e.spec.ts
+++ b/tests/e2e/setup.e2e.spec.ts
@@ -32,6 +32,8 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
'/campanha/municipios/advisors',
'/campanha/municipios/engagement-level',
'/campanha/municipios/expected-votes',
+ '/campanha/municipios/pledge-declared-votes',
+ '/campanha/municipios/pledge-estimated-votes',
'/campanha/municipios/political-trend',
'/campanha/liderancas/support-status',
'/campanha/webauthn/login-options',
diff --git a/tests/unit/municipalityV2NetworkView.unit.spec.ts b/tests/unit/municipalityV2NetworkView.unit.spec.ts
new file mode 100644
index 00000000..affb4c8e
--- /dev/null
+++ b/tests/unit/municipalityV2NetworkView.unit.spec.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+
+import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
+import {
+ buildMunicipalityV2NetworkRows,
+ MUNICIPALITY_V2_NETWORK_LIMIT,
+} from '@/utilities/municipality/municipalityV2NetworkView'
+
+describe('municipalityV2NetworkView', () => {
+ it('merges leaderships with pledges and sorts by effective central estimate', () => {
+ const { rows, totalCount } = buildMunicipalityV2NetworkRows(
+ [
+ { id: 1, name: 'Ana', supportStatus: 'engajado' },
+ { id: 2, name: 'Bruno', supportStatus: 'a_abordar' },
+ { id: 3, name: 'Carla', supportStatus: null },
+ ],
+ [
+ {
+ id: 10,
+ leadershipID: 1,
+ declaredVotes: 100,
+ estimatedVotes: toVoteEstimateScenarioViewModel({ central: 200 }),
+ },
+ {
+ id: 11,
+ leadershipID: 2,
+ declaredVotes: 500,
+ estimatedVotes: toVoteEstimateScenarioViewModel({ central: 50 }),
+ },
+ ],
+ 2,
+ )
+
+ expect(totalCount).toBe(3)
+ expect(rows.length).toBe(2)
+ expect(rows[0]?.leadershipID).toBe(1)
+ expect(rows[0]?.pledgeID).toBe(10)
+ expect(rows[0]?.declaredVotes).toBe(100)
+ expect(rows[1]?.leadershipID).toBe(2)
+ })
+
+ it('includes leaderships without pledges with null declared and empty estimates', () => {
+ const { rows } = buildMunicipalityV2NetworkRows(
+ [{ id: 5, name: 'Sem pledge', supportStatus: null }],
+ [],
+ )
+
+ expect(rows[0]).toMatchObject({
+ leadershipID: 5,
+ pledgeID: null,
+ declaredVotes: null,
+ estimatedVotes: toVoteEstimateScenarioViewModel(null),
+ })
+ })
+
+ it('defaults limit to dossier cap', () => {
+ const leaderships = Array.from({ length: 12 }, (_, index) => ({
+ id: index + 1,
+ name: `L${index + 1}`,
+ supportStatus: null,
+ }))
+
+ const { rows, totalCount } = buildMunicipalityV2NetworkRows(leaderships, [])
+ expect(totalCount).toBe(12)
+ expect(rows.length).toBe(MUNICIPALITY_V2_NETWORK_LIMIT)
+ })
+})