mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
commit
e20bbd2718
7 changed files with 452 additions and 115 deletions
81
docs/plans/municipio-v2-conta-local-impl.md
Normal file
81
docs/plans/municipio-v2-conta-local-impl.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
48
src/utilities/municipality/municipalityV2ContaData.ts
Normal file
48
src/utilities/municipality/municipalityV2ContaData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
10
src/utilities/municipality/municipalityV2ContaView.ts
Normal file
10
src/utilities/municipality/municipalityV2ContaView.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue