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.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..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 ? ( + + ) : 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/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) + }) +})