From 7d8935671046e1da47b288a3a15747cf8b312000 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:10:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?B149=20=E2=80=94=20Munic=C3=ADpio=20v2=20re?= =?UTF-8?q?de:=20lista=20edit-in-place=20com=20top-N=20lideran=C3=A7as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seção Rede na rota v2 com tabela/lista densa e CTAs Ver todas / Nova - Células autosave (B9) para declarado e estimado via JSON routes - Loader + view puro com ordenação por estimado/declarado - Revalidate v2 em writes de pledge; unit tests de merge/sort Co-authored-by: Francisco Solla --- docs/plans/municipio-v2-rede-impl.md | 89 ++++++++++ .../(app)/municipio/[slug]/v2/page.tsx | 18 +- .../municipios/[slug]/pledgeFormActions.ts | 9 +- .../municipios/pledge-declared-votes/route.ts | 53 ++++++ .../municipios/pledge-declared-votes/types.ts | 5 + .../pledge-estimated-votes/route.ts | 47 ++++++ .../pledge-estimated-votes/types.ts | 12 ++ .../MunicipalityV2DeclaredVotesCell.tsx | 121 ++++++++++++++ .../MunicipalityV2EstimatedVotesCell.tsx | 154 ++++++++++++++++++ .../MunicipalityV2NetworkList.tsx | 136 ++++++++++++++++ .../MunicipalityV2NetworkSection.tsx | 49 ++++++ .../municipality/municipalityV2NetworkData.ts | 47 ++++++ .../municipality/municipalityV2NetworkView.ts | 86 ++++++++++ .../municipalityV2NetworkView.unit.spec.ts | 67 ++++++++ 14 files changed, 881 insertions(+), 12 deletions(-) create mode 100644 docs/plans/municipio-v2-rede-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/types.ts create mode 100644 src/components/campaign/municipality/MunicipalityV2DeclaredVotesCell.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2NetworkList.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx create mode 100644 src/utilities/municipality/municipalityV2NetworkData.ts create mode 100644 src/utilities/municipality/municipalityV2NetworkView.ts create mode 100644 tests/unit/municipalityV2NetworkView.unit.spec.ts diff --git a/docs/plans/municipio-v2-rede-impl.md b/docs/plans/municipio-v2-rede-impl.md new file mode 100644 index 00000000..02105e67 --- /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..13c68993 --- /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 { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +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..6c542169 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts @@ -0,0 +1,5 @@ +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..5fdaaf9c --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge' +import { positiveRelationshipId } from '@/lib/schemas/primitives' +import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import { + VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE, + voteEstimateScenarioFieldsSchema, +} from '@/lib/schemas/votePledge' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +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..49fe0143 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx @@ -0,0 +1,154 @@ +'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( + { + 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.rows.map((row) => ( +
  • +
    + + {row.name} + + {row.supportStatus ? : null} +
    +
    +
    +
    Declarado
    +
    + +
    +
    +
    +
    Estimado
    +
    + +
    +
    +
    +
  • + ))} +
+ + {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..fdd9adac --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx @@ -0,0 +1,49 @@ +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 ? ( + + ) : null} + +
+
+ + +
+ ) +} 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/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) + }) +}) From c95d88562e71c215410a44b83f5fc2e2340c695f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:11:22 +0000 Subject: [PATCH 2/2] format + e2e prewarm for pledge autosave routes --- docs/plans/municipio-v2-rede-impl.md | 6 ++--- .../municipios/pledge-declared-votes/route.ts | 2 +- .../municipios/pledge-declared-votes/types.ts | 3 ++- .../pledge-estimated-votes/route.ts | 7 +++--- .../MunicipalityV2EstimatedVotesCell.tsx | 23 ++++++++++--------- .../MunicipalityV2NetworkSection.tsx | 4 +--- tests/e2e/setup.e2e.spec.ts | 2 ++ 7 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/plans/municipio-v2-rede-impl.md b/docs/plans/municipio-v2-rede-impl.md index 02105e67..d9575783 100644 --- a/docs/plans/municipio-v2-rede-impl.md +++ b/docs/plans/municipio-v2-rede-impl.md @@ -31,9 +31,9 @@ flowchart LR **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 | +| | 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`. 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 index 13c68993..084bc521 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/route.ts @@ -8,8 +8,8 @@ import { VOTE_PLEDGE_LEADERSHIP_REQUIRED_MESSAGE, VOTE_PLEDGE_MUNICIPALITY_NOT_LINKED_MESSAGE, } from '@/lib/schemas/votePledge' -import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import type { MunicipalityPledgeDeclaredVotesResponse } from './types' 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 index 6c542169..ea5bdcdd 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-declared-votes/types.ts @@ -1,4 +1,5 @@ -export const MUNICIPALITY_PLEDGE_DECLARED_VOTES_ENDPOINT = '/campanha/municipios/pledge-declared-votes' +export const MUNICIPALITY_PLEDGE_DECLARED_VOTES_ENDPOINT = + '/campanha/municipios/pledge-declared-votes' export type MunicipalityPledgeDeclaredVotesResponse = | { status: 'success'; message: string; savedDeclaredVotes: number; pledgeId: number } 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 index 5fdaaf9c..5c6d412c 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/pledge-estimated-votes/route.ts @@ -3,13 +3,13 @@ import { z } from 'zod' import { estimateVotes } from '@/app/(campaign)/campanha/actions/votePledge' import { positiveRelationshipId } from '@/lib/schemas/primitives' -import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import { VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE, voteEstimateScenarioFieldsSchema, } from '@/lib/schemas/votePledge' -import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' +import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' import type { MunicipalityPledgeEstimatedVotesResponse } from './types' @@ -26,8 +26,7 @@ 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.', + genericMessage: 'Não foi possível salvar a estimativa. Verifique seu acesso e tente novamente.', }, async ({ pledgeId, estimatedVotes }) => { const pledge = await estimateVotes({ diff --git a/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx b/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx index 49fe0143..177c2ba5 100644 --- a/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx +++ b/src/components/campaign/municipality/MunicipalityV2EstimatedVotesCell.tsx @@ -54,17 +54,18 @@ const MunicipalityV2EstimatedVotesCellEditable = ({ ) const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } = - useCampaignCellAutosave( - { - 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.', - }, - ) + 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') diff --git a/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx b/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx index fdd9adac..7ee7d87f 100644 --- a/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx +++ b/src/components/campaign/municipality/MunicipalityV2NetworkSection.tsx @@ -20,9 +20,7 @@ export const MunicipalityV2NetworkSection = ({ network }: MunicipalityV2NetworkS

Rede - {network.totalCount > 0 - ? ` (${formatElectionNumber(network.totalCount)})` - : ''} + {network.totalCount > 0 ? ` (${formatElectionNumber(network.totalCount)})` : ''}

Quem segura o município — ajuste declarado e estimado sem trocar de aba. 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',