Merge pull request #344 from fsolla/cursor/pool-i331-584ba384-8364

B148 — Município v2 — conta local (P/M/O + cobertura + classe)
This commit is contained in:
Francisco Solla 2026-08-02 22:50:14 -03:00 committed by GitHub
commit e20bbd2718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 452 additions and 115 deletions

View file

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

View file

@ -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 (
<CampaignPageShell>
<CampaignQuickActionContextSync municipalitySlug={context.slug} municipalityId={context.id} />
<div className="flex flex-col gap-8">
<MunicipalityV2StatusStrip
status={status}
signalFormAction={createMunicipalityV2SignalFormAction}
/>
{/* Placeholder for B148 — keep the vertical rhythm without shipping twin content. */}
<section aria-labelledby="municipio-v2-conta-title" className="flex flex-col gap-2">
<h2 id="municipio-v2-conta-title" className="text-base font-medium text-muted-foreground">
Conta local
</h2>
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
</section>
<MunicipalityV2LocalAccountSection
conta={conta}
territorialClass={status.territorialClass}
/>
<MunicipalityV2NetworkSection network={network} />
<MunicipalityV2AgoraSection agora={agora} resolveAction={resolveSuggestionFormAction} />
</div>

View file

@ -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 = ({
</CampaignHoverTooltip>
)
/**
* 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
}) => (
<div className="flex flex-col gap-1">
<p>{lead}</p>
{formula ? <p className="text-background/70">{formula}</p> : null}
<Link
href={campaignConceptHref(conceptID)}
className="font-medium underline underline-offset-2"
>
Saiba mais
</Link>
</div>
)
/**
* 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 (
<div className="flex flex-col gap-0.5">
<CampaignHoverTooltip
content={
<MetricExplanation
lead={
<>
<strong>Classe</strong> 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}
>
<button
type="button"
aria-label={`Classe: ${territorialClassLabels[territorialClass.class]}. Mais informações`}
className="flex min-h-11 w-fit items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={cn('text-xs text-muted-foreground', campaignHoverExplanationClassName)}>
Classe
</span>
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>
{territorialClassLabels[territorialClass.class]}
</Badge>
</button>
</CampaignHoverTooltip>
<p className="px-1.5 text-xs text-muted-foreground">{why}</p>
</div>
)
}
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 = ({
</div>
<div className="flex flex-col gap-3 border-t pt-3">
<TerritorialClassRow territorialClass={territorialClass} />
<MunicipalityTerritorialClassRow territorialClass={territorialClass} />
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<GoalAccountMetric
label="Teto do campo (proj.)"
value={formatElectionNumber(Math.round(potential.projectedFieldCeiling))}
explanation={
<MetricExplanation
<CampaignConceptMetricExplanation
lead={
<>
<strong>Teto do campo</strong> projeta quantos votos válidos o campo pode
@ -314,7 +221,7 @@ export const MunicipalityGoalAccountCard = ({
label="Captura (2022)"
value={formatRatioAsPercentLabel(potential.captureRate2022)}
explanation={
<MetricExplanation
<CampaignConceptMetricExplanation
lead={
<>
<strong>Captura</strong> mostra quanto do teto do campo Jorge Solla conquistou
@ -349,7 +256,7 @@ export const MunicipalityGoalAccountCard = ({
label={`Share intracampo (${latestIntraFieldShareYear})`}
value={formatRatioAsPercentLabel(latestIntraFieldShare)}
explanation={
<MetricExplanation
<CampaignConceptMetricExplanation
lead={
<>
<strong>Share intracampo</strong> 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={
<MetricExplanation
<CampaignConceptMetricExplanation
lead={
<>
<strong>Roll-off</strong> mede quanto voto em branco/nulo a disputa de deputado

View file

@ -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
}) => (
<div className="flex flex-col gap-1">
<p>{lead}</p>
{formula ? <p className="text-background/70">{formula}</p> : null}
<Link
href={campaignConceptHref(conceptID)}
className="font-medium underline underline-offset-2"
>
Saiba mais
</Link>
</div>
)
/**
* E10 — classe territorial com fatores dominantes (research §6.4).
*/
export const MunicipalityTerritorialClassRow = ({
territorialClass,
}: {
territorialClass: MunicipalityTerritorialClassification
}) => {
const why = formatTerritorialClassWhy(territorialClass.factors)
return (
<div className="flex flex-col gap-0.5">
<CampaignHoverTooltip
content={
<CampaignConceptMetricExplanation
lead={
<>
<strong>Classe</strong> 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}
>
<button
type="button"
aria-label={`Classe: ${territorialClassLabels[territorialClass.class]}. Mais informações`}
className="flex min-h-11 w-fit items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={cn('text-xs text-muted-foreground', campaignHoverExplanationClassName)}>
Classe
</span>
<Badge variant={territorialClassBadgeVariant[territorialClass.class]}>
{territorialClassLabels[territorialClass.class]}
</Badge>
</button>
</CampaignHoverTooltip>
<p className="px-1.5 text-xs text-muted-foreground">{why}</p>
</div>
)
}

View file

@ -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
}) => (
<CampaignHoverTooltip
side="bottom"
align="start"
content={
<div className="flex max-w-64 flex-col gap-2 text-sm">
<p>{campaignConceptOneLiner(conceptId)}</p>
<Link
href={campaignConceptHref(conceptId)}
className="font-medium underline underline-offset-2"
>
Ver em Conceitos
</Link>
</div>
}
>
<button
type="button"
aria-label={`${label}: mais informações`}
className={cn(
'inline-flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
campaignHoverExplanationClassName,
)}
>
<span aria-hidden="true">?</span>
</button>
</CampaignHoverTooltip>
)
export const MunicipalityV2LocalAccountSection = ({
conta,
territorialClass,
}: MunicipalityV2LocalAccountSectionProps) => {
const [activeScenario, setActiveScenario] = useState<VoteEstimateScenario>(
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 (
<section
aria-labelledby="municipio-v2-conta-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1">
<h2 id="municipio-v2-conta-title" className="text-base font-medium">
Conta local
</h2>
<ConceptHint conceptId="meta" label="Conta local" />
</div>
<CampaignInfoHint label="Sobre meta e cobertura">
<div className="flex flex-col gap-2 text-sm">
<p>{campaignConceptOneLiner('meta')}</p>
<p>{campaignConceptOneLiner('cobertura-da-meta')}</p>
<Link
href={campaignConceptHref('meta')}
className="font-medium text-primary underline underline-offset-4"
>
Ver em Conceitos
</Link>
</div>
</CampaignInfoHint>
</div>
<div
className="relative flex flex-col gap-2 rounded-lg bg-muted/40 px-3 py-3"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
{isPending ? (
<Spinner
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
aria-label="Salvando votos estimados"
/>
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={value}
idPrefix={`municipality-v2-expected-votes-${conta.municipalityID}`}
variant="compact"
activeScenario={activeScenario}
onFocusScenario={setActiveScenario}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
<p className="text-xs text-muted-foreground tabular-nums">
Meta ativa ({scenarioLabel}): {formatElectionNumber(activeCoverage.goal)} (
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida: votação de 2022 aqui'})
</p>
{statusMessage ? (
<p className="text-xs text-muted-foreground" aria-live="polite">
{statusMessage}
</p>
) : null}
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-center gap-1">
<span className="text-2xl font-semibold tabular-nums">
{formatGoalCoverageRatioLabel(activeCoverage)}
</span>
<ConceptHint conceptId="cobertura-da-meta" label="Cobertura da meta" />
</div>
<span className="text-sm text-muted-foreground">Cobertura · {scenarioLabel}</span>
</div>
{activeCoverage.coverageRatio != null ? (
<Progress
value={goalCoverageProgressPercent(activeCoverage)}
aria-label={`Cobertura da meta (${scenarioLabel}): ${formatGoalCoverageRatioLabel(activeCoverage)}`}
className="h-2"
/>
) : null}
<p className="text-xs text-muted-foreground tabular-nums">
Comprometido nas lideranças: {formatElectionNumber(activeCoverage.committed)} ·{' '}
{formatGoalCoverageDeficitLabel(activeCoverage)}
</p>
<p className="sr-only" aria-live="polite" key={activeScenario}>
{scenarioCoverageSummary}
</p>
</div>
<div className="border-t pt-3">
<MunicipalityTerritorialClassRow territorialClass={territorialClass} />
</div>
</section>
)
}

View file

@ -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<SuggestedGoalByScenario> = {
pessimistic: 0,
central: 0,
optimistic: 0,
}
export const loadMunicipalityV2ContaData = async (
payload: Payload,
user: CampaignUser,
municipalitySlug: string,
): Promise<MunicipalityV2ContaViewModel> => {
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,
}
}

View file

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