Merge pull request #341 from fsolla/cursor/pool-i332-0d1ca390-b998
B149 — Município v2 — rede (lista edit-in-place)
This commit is contained in:
commit
deefe566b0
15 changed files with 882 additions and 12 deletions
89
docs/plans/municipio-v2-rede-impl.md
Normal file
89
docs/plans/municipio-v2-rede-impl.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
|||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
|
||||
</section>
|
||||
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-2">
|
||||
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
|
||||
Rede
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Em breve nesta visão.</p>
|
||||
</section>
|
||||
<MunicipalityV2NetworkSection network={network} />
|
||||
|
||||
<section aria-labelledby="municipio-v2-agora-title" className="flex flex-col gap-2">
|
||||
<h2 id="municipio-v2-agora-title" className="text-base font-medium text-muted-foreground">
|
||||
Agora
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityPledgeDeclaredVotesResponse>({
|
||||
status: 'success',
|
||||
message: 'Votos declarados atualizados.',
|
||||
savedDeclaredVotes: pledge.declaredVotes,
|
||||
pledgeId: pledge.id,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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<MunicipalityPledgeEstimatedVotesResponse>({
|
||||
status: 'success',
|
||||
message: 'Estimativa atualizada.',
|
||||
savedEstimatedVotes: toVoteEstimateScenarioViewModel(pledge.estimatedVotes),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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<number | null, MunicipalityPledgeDeclaredVotesResponse>({
|
||||
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 ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<span className="font-medium tabular-nums">{formatElectionNumber(value)}</span>
|
||||
)
|
||||
|
||||
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 (
|
||||
<CampaignCellEditOverlay
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Editar votos declarados"
|
||||
description={leadershipName}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
triggerClassName="group flex w-full items-center justify-end"
|
||||
align="end"
|
||||
contentClassName="w-48 p-3"
|
||||
preventPopoverAutoFocus
|
||||
trigger={display}
|
||||
>
|
||||
<div className="relative flex flex-col gap-2.5">
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
|
||||
aria-label="Salvando votos declarados"
|
||||
/>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`municipality-v2-declared-${leadershipID}`}>
|
||||
Votos declarados
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`municipality-v2-declared-${leadershipID}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000000}
|
||||
inputMode="numeric"
|
||||
value={value ?? ''}
|
||||
onChange={(event) => handleInputChange(event.target.value)}
|
||||
className="min-h-11 tabular-nums"
|
||||
/>
|
||||
</Field>
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</CampaignCellEditOverlay>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<MunicipalityV2EstimatedVotesCellProps, 'pledgeID'> & {
|
||||
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<VoteEstimateScenario>('central')
|
||||
|
||||
const triggerLabel = [
|
||||
'Editar estimativa de ',
|
||||
leadershipName,
|
||||
' — ',
|
||||
voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO],
|
||||
': ',
|
||||
centralEstimate == null
|
||||
? formatElectionNumber(effectiveCentral)
|
||||
: formatElectionNumber(centralEstimate),
|
||||
].join('')
|
||||
|
||||
const display =
|
||||
centralEstimate == null ? (
|
||||
<span className="font-medium tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(effectiveCentral)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium tabular-nums">{formatElectionNumber(centralEstimate)}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Editar estimativa"
|
||||
description={leadershipName}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
triggerClassName="group flex w-full items-center justify-end"
|
||||
align="end"
|
||||
contentClassName="w-[15.5rem] p-3"
|
||||
preventPopoverAutoFocus
|
||||
trigger={display}
|
||||
>
|
||||
<div className="relative flex flex-col gap-2.5">
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
|
||||
aria-label="Salvando estimativa"
|
||||
/>
|
||||
) : null}
|
||||
<VoteEstimateScenarioInputs
|
||||
fieldPrefix="estimatedVotes"
|
||||
values={value}
|
||||
idPrefix={`municipality-v2-estimated-${pledgeID}`}
|
||||
variant="compact"
|
||||
autoFocusScenario={variant === 'sheet' ? undefined : 'central'}
|
||||
activeScenario={focusedScenario}
|
||||
onFocusScenario={setFocusedScenario}
|
||||
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</CampaignCellEditOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
export const MunicipalityV2EstimatedVotesCell = ({
|
||||
pledgeID,
|
||||
leadershipName,
|
||||
declaredVotes,
|
||||
estimatedVotes,
|
||||
variant,
|
||||
}: MunicipalityV2EstimatedVotesCellProps) => {
|
||||
if (pledgeID === null) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<MunicipalityV2EstimatedVotesCellEditable
|
||||
pledgeID={pledgeID}
|
||||
leadershipName={leadershipName}
|
||||
declaredVotes={declaredVotes}
|
||||
estimatedVotes={estimatedVotes}
|
||||
variant={variant}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
|
||||
Nenhuma liderança vinculada a este município ainda.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Declarado</TableHead>
|
||||
<TableHead className="text-right">Estimado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{network.rows.map((row) => (
|
||||
<TableRow key={row.leadershipID}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.leadershipID}`}
|
||||
className="min-h-11 content-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<MunicipalityV2DeclaredVotesCell
|
||||
municipalityID={network.municipalityID}
|
||||
leadershipID={row.leadershipID}
|
||||
leadershipName={row.name}
|
||||
declaredVotes={row.declaredVotes}
|
||||
variant="popover"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<MunicipalityV2EstimatedVotesCell
|
||||
pledgeID={row.pledgeID}
|
||||
leadershipName={row.name}
|
||||
declaredVotes={row.declaredVotes}
|
||||
estimatedVotes={row.estimatedVotes}
|
||||
variant="popover"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2 md:hidden">
|
||||
{network.rows.map((row) => (
|
||||
<li
|
||||
key={row.leadershipID}
|
||||
className="flex flex-col gap-2 rounded-xl border px-3 py-3 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.leadershipID}`}
|
||||
className="min-h-11 content-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : null}
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<dt className="text-xs text-muted-foreground">Declarado</dt>
|
||||
<dd>
|
||||
<MunicipalityV2DeclaredVotesCell
|
||||
municipalityID={network.municipalityID}
|
||||
leadershipID={row.leadershipID}
|
||||
leadershipName={row.name}
|
||||
declaredVotes={row.declaredVotes}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<dt className="text-xs text-muted-foreground">Estimado</dt>
|
||||
<dd>
|
||||
<MunicipalityV2EstimatedVotesCell
|
||||
pledgeID={row.pledgeID}
|
||||
leadershipName={row.name}
|
||||
declaredVotes={row.declaredVotes}
|
||||
estimatedVotes={row.estimatedVotes}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{network.totalCount > network.rows.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mostrando {formatElectionNumber(network.rows.length)} de{' '}
|
||||
{formatElectionNumber(network.totalCount)} lideranças — use Ver todas para o cadastro
|
||||
completo.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section aria-labelledby="municipio-v2-rede-title" className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="municipio-v2-rede-title" className="text-base font-medium text-muted-foreground">
|
||||
Rede
|
||||
{network.totalCount > 0 ? ` (${formatElectionNumber(network.totalCount)})` : ''}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quem segura o município — ajuste declarado e estimado sem trocar de aba.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{network.totalCount > network.rows.length ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={seeAllHref}>Ver todas</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={createHref}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MunicipalityV2NetworkList network={network} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
47
src/utilities/municipality/municipalityV2NetworkData.ts
Normal file
47
src/utilities/municipality/municipalityV2NetworkData.ts
Normal file
|
|
@ -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<MunicipalityV2NetworkViewModel> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
86
src/utilities/municipality/municipalityV2NetworkView.ts
Normal file
86
src/utilities/municipality/municipalityV2NetworkView.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
67
tests/unit/municipalityV2NetworkView.unit.spec.ts
Normal file
67
tests/unit/municipalityV2NetworkView.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue