From 673c5f3926c0b13de67e7b9fdc009119ca803d80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 01:27:53 +0000 Subject: [PATCH] =?UTF-8?q?B148=20=E2=80=94=20conta=20local=20na=20v2=20(P?= =?UTF-8?q?/M/O=20+=20cobertura=20+=20classe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadMunicipalityV2ContaData + MunicipalityV2LocalAccountSection - Inline edit-in-place com cenário ativo local para cobertura - Extract MunicipalityTerritorialClassRow do card E8 - Integra com B149 rede e B150 agora na mesma página v2 - Impl plan docs/plans/municipio-v2-conta-local-impl.md --- docs/plans/municipio-v2-conta-local-impl.md | 81 +++++++ .../(app)/municipio/[slug]/v2/page.tsx | 23 +- .../MunicipalityGoalAccountCard.tsx | 113 +--------- .../MunicipalityTerritorialClassRow.tsx | 83 +++++++ .../MunicipalityV2LocalAccountSection.tsx | 209 ++++++++++++++++++ .../municipality/municipalityV2ContaData.ts | 48 ++++ .../municipality/municipalityV2ContaView.ts | 10 + 7 files changed, 452 insertions(+), 115 deletions(-) create mode 100644 docs/plans/municipio-v2-conta-local-impl.md create mode 100644 src/components/campaign/municipality/MunicipalityTerritorialClassRow.tsx create mode 100644 src/components/campaign/municipality/MunicipalityV2LocalAccountSection.tsx create mode 100644 src/utilities/municipality/municipalityV2ContaData.ts create mode 100644 src/utilities/municipality/municipalityV2ContaView.ts diff --git a/docs/plans/municipio-v2-conta-local-impl.md b/docs/plans/municipio-v2-conta-local-impl.md new file mode 100644 index 00000000..b64ea4d3 --- /dev/null +++ b/docs/plans/municipio-v2-conta-local-impl.md @@ -0,0 +1,81 @@ +# Impl: Município v2 — conta local (P/M/O + cobertura + classe) + +Status: aprovado +Atualizado em: 2026-08-03 +Issue: #331 +Intenção: docs/plans/municipio-v2-conta-local.md +Appetite restante: ~1 dia eng (herdado) + +## Leitura da intenção + +- **Outcome:** na dobra principal da v2, staff vê e edita meta P/M/O no lugar; clicar/focar um cenário ativa cobertura/déficit daquele cenário; classe territorial com destaque e fatores; tooltips com one-liner + link a conceitos; sem abrir Eleições. +- **O que NÃO negociar:** leader lockdown; um único número escondendo P/M/O; três coberturas simultâneas; grade E8 completa na 1ª dobra; % estadual absoluto; segundo significado de cenário. +- **O que reavaliar:** hipótese de “bloco na composição v2” (ok); reusar popover da lista vs inline edit-in-place; estender `loadMunicipalityV2StatusData` vs loader conta dedicado. + +## Abordagem recomendada + +```mermaid +flowchart LR + Page["municipio/[slug]/v2/page.tsx"] + Loader["loadMunicipalityV2ContaData"] + Section["MunicipalityV2LocalAccountSection"] + Inline["VoteEstimateScenarioInputs + useCampaignCellAutosave"] + API["POST …/expected-votes"] + Page --> Loader --> Section + Section --> Inline --> API + Section -->|"computeGoalCoverageByScenario (client)"| Coverage["Cobertura cenário ativo"] +``` + +**Opções consideradas:** + +| | A — Popover list control na v2 | B — Inline P/M/O + cenário local + cobertura reativa | C — Card E8 completo na dobra | +| --- | -------------------------------------------- | ---------------------------------------------------- | ----------------------------- | +| | Reusa `MunicipalityListExpectedVotesControl` | Inputs visíveis; cenário ativo local | Viola anti-goal produto | + +**Recomendação:** B — produto pede campos editáveis no lugar e clique ativa cenário; popover esconde P/M/O; card E8 é fora de escopo. + +**Rejeitadas:** A (popover ≠ in-place); C (corte explícito na intenção). + +### Componentes / mudanças + +- **`loadMunicipalityV2ContaData`** (`src/utilities/municipality/municipalityV2ContaData.ts`): composer server-only — context + expectedVotes + pledge aggregate + `loadStatewideSuggestedGoals` + `computeGoalCoverageByScenario` + territorial class. +- **`MunicipalityV2ContaViewModel`** (`src/utilities/municipality/municipalityV2ContaView.ts`): tipo serializável para a seção. +- **`MunicipalityV2LocalAccountSection`** (`src/components/campaign/municipality/`): client; inline autosave (mesmo endpoint B9); `activeScenario` local; cobertura recalculada com `computeGoalCoverageByScenario` ao editar; progress + déficit; classe com fatores. +- **`MunicipalityTerritorialClassRow`** (extract de `MunicipalityGoalAccountCard`): dono compartilhado da linha classe+fatores+tooltip — editar o owner, não twin. +- **`page.tsx`**: substituir placeholder pela seção; `Promise.all` status + conta. +- **Migration:** nenhuma. +- **Access:** page gate `noLeader`; write via `setMunicipalityExpectedVotes` existente. +- **UI:** Impeccable B — shape denso na dobra; `CampaignHoverTooltip` + `campaignConceptOneLiner`; `Progress` h-2 como no card E8. + +### Dados → forma + +- P/M/O: três inputs compactos numa linha (mesmo `VoteEstimateScenarioInputs` compact). +- Cobertura: uma leitura grande (% + barra + déficit) do cenário ativo; meta e comprometido em sublinha. +- Classe: pill + dois fatores (reuso `TerritorialClassRow`). + +## Fases verificáveis + +1. **Loader + view model** — `municipalityV2ContaData` + unit smoke se helper puro. +2. **UI** — seção client + extract territorial row + wire page. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Cenário global `MunicipalityEstimateScenarioProvider` na v2. +- Diagnósticos E8 (teto, captura, roll-off) na dobra. +- `router.refresh()` obrigatório — cobertura recalcula client-side com `computeGoalCoverageByScenario`. + +## Riscos e mitigação + +- **Drift cobertura pós-save:** `useCampaignCellAutosave` adota `savedExpectedVotes` da resposta; recomputamos cobertura localmente com os mesmos pure helpers do servidor. +- **Twin TerritorialClassRow:** extract para componente compartilhado no mesmo PR. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes: unit no loader/view se lógica pura; int opcional (cobertura já testada em `goalCoverage.unit.spec.ts`) + +## Débitos deferidos (capture-review-debts) + +- Link “mais detalhe da conta” para overview/E8 — gatilho: entrega B151 ou pedido explícito de deep-link. 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 f7136b55..cca951cc 100644 --- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx @@ -5,15 +5,16 @@ import { getPayload } from 'payload' import { createMunicipalityV2SignalFormAction } from '@/app/(campaign)/campanha/(app)/municipio/[slug]/v2/formActions' import { resolveSuggestionFormAction } from '@/app/(campaign)/campanha/(app)/suggestionFormActions' import { MunicipalityV2AgoraSection } from '@/components/campaign/municipality/MunicipalityV2AgoraSection' +import { MunicipalityV2LocalAccountSection } from '@/components/campaign/municipality/MunicipalityV2LocalAccountSection' import { MunicipalityV2NetworkSection } from '@/components/campaign/municipality/MunicipalityV2NetworkSection' import { MunicipalityV2StatusStrip } from '@/components/campaign/municipality/MunicipalityV2StatusStrip' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' -import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { MunicipalityNotFoundError } from '@/utilities/municipality/municipalityPageData' import { loadMunicipalityV2AgoraData } from '@/utilities/municipality/municipalityV2AgoraData' +import { loadMunicipalityV2ContaData } from '@/utilities/municipality/municipalityV2ContaData' import { loadMunicipalityV2NetworkData } from '@/utilities/municipality/municipalityV2NetworkData' import { loadMunicipalityV2StatusData } from '@/utilities/municipality/municipalityV2StatusData' @@ -36,16 +37,18 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP ]) let status - let context + let conta let network let agora try { - const [statusLoaded, networkLoaded, agoraLoaded] = await Promise.all([ + const [statusLoaded, contaLoaded, networkLoaded, agoraLoaded] = await Promise.all([ loadMunicipalityV2StatusData(payload, user, slug), + loadMunicipalityV2ContaData(payload, user, slug), loadMunicipalityV2NetworkData(payload, user, slug), loadMunicipalityV2AgoraData(payload, user, slug), ]) - ;({ status, context } = statusLoaded) + ;({ status } = statusLoaded) + conta = contaLoaded network = networkLoaded agora = agoraLoaded } catch (error) { @@ -55,20 +58,16 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP return ( -
- {/* Placeholder for B148 — keep the vertical rhythm without shipping twin content. */} -
-

- Conta local -

-

Em breve nesta visão.

-
+
diff --git a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx index 03f9b13f..58d5ed46 100644 --- a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx @@ -2,16 +2,15 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl' +import { + CampaignConceptMetricExplanation, + MunicipalityTerritorialClassRow, +} from '@/components/campaign/municipality/MunicipalityTerritorialClassRow' import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint' -import { Badge } from '@/components/ui/Badge' import { Progress } from '@/components/ui/Progress' import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip' -import { - CAMPAIGN_CONCEPTS_PATH, - campaignConceptHref, - type CampaignConceptId, -} from '@/lib/campaignIntelligenceConcepts' +import { CAMPAIGN_CONCEPTS_PATH } from '@/lib/campaignIntelligenceConcepts' import { formatElectionNumber } from '@/lib/electionFormat' import { cn } from '@/lib/utils' import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' @@ -22,11 +21,6 @@ import { goalCoverageProgressPercent, type MunicipalityGoalCoverage, } from '@/utilities/municipality/goalCoverage' -import { - formatTerritorialClassWhy, - territorialClassBadgeVariant, - territorialClassLabels, -} from '@/utilities/municipality/municipalityLabels' import type { MunicipalityPotential, RollOff } from '@/utilities/municipality/municipalityPotential' import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass' import type { MunicipalityIntraTerritoryCaptureBenchmark } from '@/utilities/territory/territoryIntraCaptureBenchmark' @@ -74,93 +68,6 @@ const GoalAccountMetric = ({ ) -/** - * Two-tier tooltip copy: a short lead sentence naming what the metric - * measures (its one bolded term), then a visually secondary formula line — - * split per the E8 `/impeccable critique` finding that a single ~40-word - * sentence with three technical terms didn't resolve the coordinator's - * confusion. `formula` sits at `text-background/70` (tooltip content is - * dark, so this lightens rather than muting against a light background). - * - * `conceptID` appends E18's "Saiba mais" deep link (every caller documents - * one, so this is required, not optional). The tooltip content is hoverable - * (Radix keeps it open while the pointer is inside) and its own touch-dismiss - * ignores taps on the content, so mouse and touch both reach the link; a - * keyboard user can't tab into tooltip content, which is why the card - * title's `CampaignInfoHint` Popover carries the same link. - */ -const MetricExplanation = ({ - lead, - formula, - conceptID, -}: { - lead: ReactNode - formula?: ReactNode - conceptID: CampaignConceptId -}) => ( -
-

{lead}

- {formula ?

{formula}

: null} - - Saiba mais - -
-) - -/** - * E10 — the class is the one-word reading of the four diagnostics right - * below it, so it opens that block instead of getting a card of its own. - * Never the label alone: the two dominant factors ride with it (research - * §6.4 — a bare label reads as a verdict and buys false confidence). - */ -const TerritorialClassRow = ({ - territorialClass, -}: { - territorialClass: MunicipalityTerritorialClassification -}) => { - const why = formatTerritorialClassWhy(territorialClass.factors) - - return ( -
- - Classe resume o que este município pede: defender (Reduto), abrir - rede (Expansão), manter o padrão (Manutenção) ou não gastar perna (Marginal). Sem - série do TSE, a classe fica em Sem base. - - } - formula="Leitura relativa: o desempenho aqui contra o padrão estadual do próprio candidato, cruzado com quanto da votação dele vem daqui e quanto voto do campo segue sem captura. É sugestão, não sentença." - conceptID="classe-territorial" - /> - } - side="right" - align="start" - sideOffset={8} - > - - -

{why}

-
- ) -} - const formatRollOff = (rollOff: RollOff | null): string => { if (!rollOff) return 'Sem majoritária 2022 disponível' const votesLabel = formatElectionNumber(rollOff.votes) @@ -292,13 +199,13 @@ export const MunicipalityGoalAccountCard = ({
- +
Teto do campo projeta quantos votos válidos o campo pode @@ -314,7 +221,7 @@ export const MunicipalityGoalAccountCard = ({ label="Captura (2022)" value={formatRatioAsPercentLabel(potential.captureRate2022)} explanation={ - Captura mostra quanto do teto do campo Jorge Solla conquistou @@ -349,7 +256,7 @@ export const MunicipalityGoalAccountCard = ({ label={`Share intracampo (${latestIntraFieldShareYear})`} value={formatRatioAsPercentLabel(latestIntraFieldShare)} explanation={ - Share intracampo mostra a fração dos votos do campo, só na @@ -365,7 +272,7 @@ export const MunicipalityGoalAccountCard = ({ label="Roll-off (2022)" value={formatRollOff(potential.rollOff2022)} explanation={ - Roll-off mede quanto voto em branco/nulo a disputa de deputado diff --git a/src/components/campaign/municipality/MunicipalityTerritorialClassRow.tsx b/src/components/campaign/municipality/MunicipalityTerritorialClassRow.tsx new file mode 100644 index 00000000..5168c300 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityTerritorialClassRow.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { Badge } from '@/components/ui/Badge' +import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip' +import { campaignConceptHref, type CampaignConceptId } from '@/lib/campaignIntelligenceConcepts' +import { cn } from '@/lib/utils' +import { + formatTerritorialClassWhy, + territorialClassBadgeVariant, + territorialClassLabels, +} from '@/utilities/municipality/municipalityLabels' +import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass' + +export const CampaignConceptMetricExplanation = ({ + lead, + formula, + conceptID, +}: { + lead: ReactNode + formula?: ReactNode + conceptID: CampaignConceptId +}) => ( +
+

{lead}

+ {formula ?

{formula}

: null} + + Saiba mais + +
+) + +/** + * E10 — classe territorial com fatores dominantes (research §6.4). + */ +export const MunicipalityTerritorialClassRow = ({ + territorialClass, +}: { + territorialClass: MunicipalityTerritorialClassification +}) => { + const why = formatTerritorialClassWhy(territorialClass.factors) + + return ( +
+ + Classe resume o que este município pede: defender (Reduto), abrir + rede (Expansão), manter o padrão (Manutenção) ou não gastar perna (Marginal). Sem + série do TSE, a classe fica em Sem base. + + } + formula="Leitura relativa: o desempenho aqui contra o padrão estadual do próprio candidato, cruzado com quanto da votação dele vem daqui e quanto voto do campo segue sem captura. É sugestão, não sentença." + conceptID="classe-territorial" + /> + } + side="right" + align="start" + sideOffset={8} + > + + +

{why}

+
+ ) +} diff --git a/src/components/campaign/municipality/MunicipalityV2LocalAccountSection.tsx b/src/components/campaign/municipality/MunicipalityV2LocalAccountSection.tsx new file mode 100644 index 00000000..abf2135a --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityV2LocalAccountSection.tsx @@ -0,0 +1,209 @@ +'use client' + +import Link from 'next/link' +import { useMemo, useState } from 'react' + +import { + MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, + type MunicipalityListExpectedVotesResponse, +} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' +import { MunicipalityTerritorialClassRow } from '@/components/campaign/municipality/MunicipalityTerritorialClassRow' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { CampaignInfoHint } from '@/components/campaign/shared/CampaignInfoHint' +import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave' +import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs' +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { Progress } from '@/components/ui/Progress' +import { Spinner } from '@/components/ui/Spinner' +import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip' +import { campaignConceptHref, campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts' +import { formatElectionNumber } from '@/lib/electionFormat' +import { cn } from '@/lib/utils' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + getVoteEstimateForScenario, + voteEstimateScenarioLabels, + voteEstimatesEqual, + type VoteEstimateScenario, +} from '@/lib/voteEstimate' +import { + computeGoalCoverageByScenario, + formatGoalCoverageDeficitLabel, + formatGoalCoverageRatioLabel, + goalCoverageProgressPercent, +} from '@/utilities/municipality/goalCoverage' +import type { MunicipalityTerritorialClassification } from '@/utilities/municipality/municipalityTerritorialClass' +import type { MunicipalityV2ContaViewModel } from '@/utilities/municipality/municipalityV2ContaView' + +const AUTOSAVE_MS = 600 + +type MunicipalityV2LocalAccountSectionProps = { + conta: MunicipalityV2ContaViewModel + territorialClass: MunicipalityTerritorialClassification +} + +const ConceptHint = ({ + conceptId, + label, +}: { + conceptId: 'meta' | 'cobertura-da-meta' + label: string +}) => ( + +

{campaignConceptOneLiner(conceptId)}

+ + Ver em Conceitos + +
+ } + > + + +) + +export const MunicipalityV2LocalAccountSection = ({ + conta, + territorialClass, +}: MunicipalityV2LocalAccountSectionProps) => { + const [activeScenario, setActiveScenario] = useState( + DEFAULT_VOTE_ESTIMATE_SCENARIO, + ) + + const { value, change, isPending, errorMessage, statusMessage } = useCampaignCellAutosave< + MunicipalityV2ContaViewModel['expectedVotes'], + MunicipalityListExpectedVotesResponse + >({ + value: conta.expectedVotes, + equals: voteEstimatesEqual, + endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + buildBody: (expectedVotes) => ({ + municipalityId: conta.municipalityID, + expectedVotes, + }), + readSaved: (payload) => payload.savedExpectedVotes, + errorMessage: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, + pendingMessage: 'Salvando votos estimados.', + }) + + const coverageByScenario = useMemo( + () => + computeGoalCoverageByScenario(value, conta.suggestedGoalByScenario, conta.pledgeAggregate), + [value, conta.suggestedGoalByScenario, conta.pledgeAggregate], + ) + + const activeCoverage = coverageByScenario[activeScenario] + const usesMesaEstimate = getVoteEstimateForScenario(value, activeScenario) != null + const scenarioLabel = voteEstimateScenarioLabels[activeScenario] + const scenarioCoverageSummary = `${scenarioLabel}: ${formatGoalCoverageRatioLabel(activeCoverage)}. ${formatGoalCoverageDeficitLabel(activeCoverage)}` + + return ( +
+
+
+

+ Conta local +

+ +
+ +
+

{campaignConceptOneLiner('meta')}

+

{campaignConceptOneLiner('cobertura-da-meta')}

+ + Ver em Conceitos + +
+
+
+ +
+ {isPending ? ( + + ) : null} + change(values, AUTOSAVE_MS)} + /> +

+ Meta ativa ({scenarioLabel}): {formatElectionNumber(activeCoverage.goal)} ( + {usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida: votação de 2022 aqui'}) +

+ {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ +
+
+
+ + {formatGoalCoverageRatioLabel(activeCoverage)} + + +
+ Cobertura · {scenarioLabel} +
+ {activeCoverage.coverageRatio != null ? ( + + ) : null} +

+ Comprometido nas lideranças: {formatElectionNumber(activeCoverage.committed)} ·{' '} + {formatGoalCoverageDeficitLabel(activeCoverage)} +

+

+ {scenarioCoverageSummary} +

+
+ +
+ +
+
+ ) +} diff --git a/src/utilities/municipality/municipalityV2ContaData.ts b/src/utilities/municipality/municipalityV2ContaData.ts new file mode 100644 index 00000000..9215a184 --- /dev/null +++ b/src/utilities/municipality/municipalityV2ContaData.ts @@ -0,0 +1,48 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import type { CampaignUser } from '@/payload-types' +import { loadStatewideSuggestedGoals } from '@/utilities/municipality/municipalityGoalAccount' +import { + getMunicipalityDetailViewModel, + resolveAccessibleMunicipalityContext, +} from '@/utilities/municipality/municipalityPageData' +import type { SuggestedGoalByScenario } from '@/utilities/municipality/municipalityPotential' +import type { MunicipalityV2ContaViewModel } from '@/utilities/municipality/municipalityV2ContaView' +import { loadMunicipalityPledges } from '@/utilities/votePledgeData' +import { aggregateMunicipalityPledgesFromRows } from '@/utilities/votePledgeViews' + +const emptySuggestedGoalByScenario: Readonly = { + pessimistic: 0, + central: 0, + optimistic: 0, +} + +export const loadMunicipalityV2ContaData = async ( + payload: Payload, + user: CampaignUser, + municipalitySlug: string, +): Promise => { + const context = await resolveAccessibleMunicipalityContext(payload, user, municipalitySlug) + const [view, pledges, { suggestedGoalBySlug }] = await Promise.all([ + getMunicipalityDetailViewModel(payload, context, user), + loadMunicipalityPledges(payload, user, context.id), + loadStatewideSuggestedGoals(payload, user), + ]) + + const strategy = view.strategy + if (!strategy) { + throw new Error('Municipality v2 conta requires a staff strategy view model.') + } + + const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges) + const suggestedGoalByScenario = suggestedGoalBySlug.get(view.slug) ?? emptySuggestedGoalByScenario + + return { + municipalityID: view.id, + expectedVotes: strategy.expectedVotes, + suggestedGoalByScenario, + pledgeAggregate, + } +} diff --git a/src/utilities/municipality/municipalityV2ContaView.ts b/src/utilities/municipality/municipalityV2ContaView.ts new file mode 100644 index 00000000..89c3ed5a --- /dev/null +++ b/src/utilities/municipality/municipalityV2ContaView.ts @@ -0,0 +1,10 @@ +import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import type { SuggestedGoalByScenario } from '@/utilities/municipality/municipalityPotential' +import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews' + +export type MunicipalityV2ContaViewModel = { + municipalityID: number + expectedVotes: VoteEstimateScenarioViewModel + suggestedGoalByScenario: SuggestedGoalByScenario + pledgeAggregate: MunicipalityPledgeAggregate +}