Merge pull request #341 from fsolla/cursor/pool-i332-0d1ca390-b998

B149 — Município v2 — rede (lista edit-in-place)
This commit is contained in:
Francisco Solla 2026-08-02 21:24:44 -03:00 committed by GitHub
commit deefe566b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 882 additions and 12 deletions

View file

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

View file

@ -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
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
Rede
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<MunicipalityV2NetworkSection network={network} />
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
Agora

View file

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

View file

@ -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<MunicipalityPledgeDeclaredVotesResponse>({
status: 'success',
message: 'Votos declarados atualizados.',
savedDeclaredVotes: pledge.declaredVotes,
pledgeId: pledge.id,
})
},
)

View file

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

View file

@ -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<MunicipalityPledgeEstimatedVotesResponse>({
status: 'success',
message: 'Estimativa atualizada.',
savedEstimatedVotes: toVoteEstimateScenarioViewModel(pledge.estimatedVotes),
})
},
)

View file

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

View file

@ -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<number | null, MunicipalityPledgeDeclaredVotesResponse>({
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 ? (
<span className="text-muted-foreground">—</span>
) : (
<span className="font-medium tabular-nums">{formatElectionNumber(value)}</span>
)
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 (
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={onOpenChange}
title="Editar votos declarados"
description={leadershipName}
triggerLabel={triggerLabel}
triggerBusy={isPending}
statusMessage={statusMessage}
triggerClassName="group flex w-full items-center justify-end"
align="end"
contentClassName="w-48 p-3"
preventPopoverAutoFocus
trigger={display}
>
<div className="relative flex flex-col gap-2.5">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando votos declarados"
/>
) : null}
<Field>
<FieldLabel htmlFor={`municipality-v2-declared-${leadershipID}`}>
Votos declarados
</FieldLabel>
<Input
id={`municipality-v2-declared-${leadershipID}`}
type="number"
min={0}
max={1000000}
inputMode="numeric"
value={value ?? ''}
onChange={(event) => handleInputChange(event.target.value)}
className="min-h-11 tabular-nums"
/>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
</div>
</CampaignCellEditOverlay>
)
}

View file

@ -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<MunicipalityV2EstimatedVotesCellProps, 'pledgeID'> & {
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<VoteEstimateScenario>('central')
const triggerLabel = [
'Editar estimativa de ',
leadershipName,
' — ',
voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO],
': ',
centralEstimate == null
? formatElectionNumber(effectiveCentral)
: formatElectionNumber(centralEstimate),
].join('')
const display =
centralEstimate == null ? (
<span className="font-medium tabular-nums text-muted-foreground">
{formatElectionNumber(effectiveCentral)}
</span>
) : (
<span className="font-medium tabular-nums">{formatElectionNumber(centralEstimate)}</span>
)
return (
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={onOpenChange}
title="Editar estimativa"
description={leadershipName}
triggerLabel={triggerLabel}
triggerBusy={isPending}
statusMessage={statusMessage}
triggerClassName="group flex w-full items-center justify-end"
align="end"
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus
trigger={display}
>
<div className="relative flex flex-col gap-2.5">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando estimativa"
/>
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="estimatedVotes"
values={value}
idPrefix={`municipality-v2-estimated-${pledgeID}`}
variant="compact"
autoFocusScenario={variant === 'sheet' ? undefined : 'central'}
activeScenario={focusedScenario}
onFocusScenario={setFocusedScenario}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
</div>
</CampaignCellEditOverlay>
)
}
export const MunicipalityV2EstimatedVotesCell = ({
pledgeID,
leadershipName,
declaredVotes,
estimatedVotes,
variant,
}: MunicipalityV2EstimatedVotesCellProps) => {
if (pledgeID === null) {
return <span className="text-muted-foreground">—</span>
}
return (
<MunicipalityV2EstimatedVotesCellEditable
pledgeID={pledgeID}
leadershipName={leadershipName}
declaredVotes={declaredVotes}
estimatedVotes={estimatedVotes}
variant={variant}
/>
)
}

View file

@ -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 (
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
Nenhuma liderança vinculada a este município ainda.
</p>
)
}
return (
<>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Declarado</TableHead>
<TableHead className="text-right">Estimado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{network.rows.map((row) => (
<TableRow key={row.leadershipID}>
<TableCell>
<Link
href={`/campanha/liderancas/${row.leadershipID}`}
className="min-h-11 content-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : null}
</TableCell>
<TableCell className="text-right">
<MunicipalityV2DeclaredVotesCell
municipalityID={network.municipalityID}
leadershipID={row.leadershipID}
leadershipName={row.name}
declaredVotes={row.declaredVotes}
variant="popover"
/>
</TableCell>
<TableCell className="text-right">
<MunicipalityV2EstimatedVotesCell
pledgeID={row.pledgeID}
leadershipName={row.name}
declaredVotes={row.declaredVotes}
estimatedVotes={row.estimatedVotes}
variant="popover"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ul className="flex flex-col gap-2 md:hidden">
{network.rows.map((row) => (
<li
key={row.leadershipID}
className="flex flex-col gap-2 rounded-xl border px-3 py-3 text-sm"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<Link
href={`/campanha/liderancas/${row.leadershipID}`}
className="min-h-11 content-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : null}
</div>
<dl className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Declarado</dt>
<dd>
<MunicipalityV2DeclaredVotesCell
municipalityID={network.municipalityID}
leadershipID={row.leadershipID}
leadershipName={row.name}
declaredVotes={row.declaredVotes}
variant="sheet"
/>
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">Estimado</dt>
<dd>
<MunicipalityV2EstimatedVotesCell
pledgeID={row.pledgeID}
leadershipName={row.name}
declaredVotes={row.declaredVotes}
estimatedVotes={row.estimatedVotes}
variant="sheet"
/>
</dd>
</div>
</dl>
</li>
))}
</ul>
{network.totalCount > network.rows.length ? (
<p className="text-xs text-muted-foreground">
Mostrando {formatElectionNumber(network.rows.length)} de{' '}
{formatElectionNumber(network.totalCount)} lideranças — use Ver todas para o cadastro
completo.
</p>
) : null}
</>
)
}

View file

@ -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 (
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
Rede
{network.totalCount > 0 ? ` (${formatElectionNumber(network.totalCount)})` : ''}
</h2>
<p className="text-sm text-muted-foreground">
Quem segura o município — ajuste declarado e estimado sem trocar de aba.
</p>
</div>
<div className="flex flex-wrap gap-2">
{network.totalCount > network.rows.length ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={seeAllHref}>Ver todas</Link>
</Button>
) : null}
<Button asChild variant="outline" className="min-h-11">
<Link href={createHref}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</div>
</div>
<MunicipalityV2NetworkList network={network} />
</section>
)
}

View file

@ -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<MunicipalityV2NetworkViewModel> => {
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,
}
}

View file

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

View file

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

View file

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