From 34e5c6ed421eeac45b24e31df9387116b976a51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 01:16:19 -0300 Subject: [PATCH 1/3] B61: wizard vote step with scenario passes and batch save. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff can adjust municipality expected votes through média→pessimista→otimista steps with shortcuts, coherence warnings, and a single POST on completion. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/ajuste-votos-wizard.md | 31 +- docs/roadmap.md | 4 +- .../campanha/(app)/acoes/[slug]/page.tsx | 27 +- .../(app)/municipios/expected-votes/types.ts | 2 + .../MunicipalityListExpectedVotesControl.tsx | 8 +- .../shared/WizardExpectedVotesStep.tsx | 294 ++++++++++++++++++ src/lib/campaignActionRoutes.ts | 28 +- src/lib/campaignWizardCopy.ts | 20 +- src/lib/wizardVoteEstimate.ts | 145 +++++++++ tests/e2e/campaignHomeActions.e2e.spec.ts | 53 +++- tests/unit/campaignActionRoutes.unit.spec.ts | 13 + tests/unit/wizardVoteEstimate.unit.spec.ts | 88 ++++++ 13 files changed, 700 insertions(+), 14 deletions(-) create mode 100644 src/components/campaign/shared/WizardExpectedVotesStep.tsx create mode 100644 src/lib/wizardVoteEstimate.ts create mode 100644 tests/unit/wizardVoteEstimate.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 0a615b08..9c1e44ff 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos). - **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal). - **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md). - **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**. diff --git a/docs/plans/ajuste-votos-wizard.md b/docs/plans/ajuste-votos-wizard.md index 1090a541..7dfffe9f 100644 --- a/docs/plans/ajuste-votos-wizard.md +++ b/docs/plans/ajuste-votos-wizard.md @@ -1,12 +1,41 @@ # Etapa de ajuste de votos no wizard (cenários + atalhos) -Status: rascunho +Status: **entregue** (2026-07-29) Atualizado em: 2026-07-29 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B61 — UX-1 wizards) Impeccable: C — UI nova de passo numérico com coerência entre cenários Appetite: ~1–1,25 dia eng; 3 sub-passos de cenário + atalhos + navegação de violação; grava via action existente no wiring A1 (neste item: UI + validação client espelhando servidor) Responsável: — +## As-built (entrega) + +### Contrato de URL e ordem de edição + +- `?cenario=central|pessimistic|optimistic` em `/campanha/acoes/atualizar-votos?municipio=`; default `central`; valor inválido → redirect canônico para `central`. +- Ordem ritual **média → pessimista → otimista** (`WIZARD_VOTE_SCENARIO_EDIT_ORDER` em `src/lib/wizardVoteEstimate.ts`); invariante de armazenamento continua `pessimistic ≤ central ≤ optimistic` via `getVoteEstimateOrderViolation`. +- Helpers: `applyVoteShortcut`, `getWizardVoteViolation`, `wizardActionHref(slug, municipio?, scenario?)`. + +### Gravação + +- **Um POST batch** ao concluir o passo otimista (`POST /campanha/municipios/expected-votes` → `setMunicipalityExpectedVotes`), não três writes por cenário. +- Sucesso: mensagem + placeholder do próximo passo A1 (B63) + link "Voltar ao Início". + +### UI + +- `WizardExpectedVotesStep` substitui `WizardMunicipalitySelectedStub` **somente** em `atualizar-votos` com município escolhido. +- Um cenário visível; input grande (`text-2xl`/`text-3xl`); atalhos `2×` · `±50` · `±100`; CTA nomeado por cenário. +- Violação: banner warning + `router.replace` para o cenário quebrado + botão "Voltar para [cenário editado]". +- `CampaignWizardShell`: caption sticky; `previousHref` = cenário anterior ou busca B60. + +### Testes + +- Unit: `tests/unit/wizardVoteEstimate.unit.spec.ts`, `campaignActionRoutes.unit.spec.ts`. +- E2e: `campaignHomeActions.e2e.spec.ts` — avanço média→pessimista; violação pessimista> média → banner + retorno. + +### Fora (inalterado) + +- Preview cobertura E8 no passo; wiring completo A1 (sinal/tendência/resumo B63/B64). + ## Design (Impeccable) Âncoras: `PRODUCT.md` (Feel the action; Clarity under pressure) / `DESIGN.md` · `VoteEstimateScenarioInputs` / `MunicipalityListExpectedVotesControl` · tema `campaign`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c423ace..13e10ab2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/B61–B64. +Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B62**~~ ✓ simplificar sinal (E11 triage 1 por presença); ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B61**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/~~**B61**~~ ✓/B63–B64. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -91,7 +91,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md) - **B59** ~~Chassis do wizard~~ — **entregue 2026-07-29** (`CampaignWizardShell`; `/campanha/acoes/*` com layout staff-only; Voltar `md:hidden`; caption sticky de município; catálogo B45 com hrefs; passo ponte até B60) · [plano](plans/chassis-wizard-campanha.md) - **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md) -- **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md) +- **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md) - ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md) - **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md) - **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md) diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index d4068aa1..46aba292 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -1,14 +1,19 @@ -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' +import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep' import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep' import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub' import { CAMPAIGN_WIZARD_ACTION_SLUGS, isCampaignWizardActionSlug, parseWizardMunicipioParam, + resolveWizardScenarioParam, WIZARD_MUNICIPIO_QUERY_KEY, + WIZARD_SCENARIO_QUERY_KEY, + wizardActionHref, } from '@/lib/campaignActionRoutes' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' +import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import config from '@/payload.config' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' @@ -52,5 +57,25 @@ export default async function CampaignActionWizardPage({ notFound() } + if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) { + const { scenario, invalid } = resolveWizardScenarioParam( + resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY], + ) + if (invalid) { + redirect(wizardActionHref(slug, municipalitySlug, 'central')) + } + + return ( + + ) + } + return } diff --git a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts index 6d1bf38a..56111a76 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts @@ -1,5 +1,7 @@ import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const + export type MunicipalityListExpectedVotesResponse = | { status: 'success' diff --git a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx index e2ac15c2..0a60b630 100644 --- a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx @@ -1,6 +1,9 @@ 'use client' -import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' +import { + MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + type MunicipalityListExpectedVotesResponse, +} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay' import { useCampaignCellAutosave } from '@/components/campaign/shared/useCampaignCellAutosave' @@ -18,7 +21,6 @@ import { import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' const AUTOSAVE_MS = 600 -const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.' /** @@ -52,7 +54,7 @@ export const MunicipalityListExpectedVotesControl = ({ useCampaignCellAutosave({ value: expectedVotes, equals: voteEstimatesEqual, - endpoint: EXPECTED_VOTES_ENDPOINT, + endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }), readSaved: (payload) => payload.savedExpectedVotes, errorMessage: SAVE_ERROR_MESSAGE, diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx new file mode 100644 index 00000000..88c3c023 --- /dev/null +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -0,0 +1,294 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useEffect, useRef, useState, useTransition } from 'react' + +import { + MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + type MunicipalityListExpectedVotesResponse, +} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' +import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' +import { Alert, AlertDescription } from '@/components/ui/Alert' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Spinner } from '@/components/ui/Spinner' +import { wizardActionHref } from '@/lib/campaignActionRoutes' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { CAMPAIGN_HOME } from '@/lib/campaignPaths' +import { + WIZARD_VOTES_INVALID_DRAFT_MESSAGE, + WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER, + WIZARD_VOTES_SAVE_ERROR_MESSAGE, + WIZARD_VOTES_SAVED_MESSAGE, + wizardVoteReturnToScenarioLabel, +} from '@/lib/campaignWizardCopy' +import { formatElectionNumber } from '@/lib/electionFormat' +import { cn } from '@/lib/utils' +import { voteEstimateScenarioLabels, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' +import { + applyVoteShortcut, + draftTextForVoteValue, + getNextWizardVoteScenario, + getPreviousWizardVoteScenario, + getWizardVoteViolation, + mergeWizardVoteEstimate, + parseWizardVoteDraft, + VOTE_SHORTCUTS, + voteShortcutLabels, + wizardVoteFinalCtaLabel, + wizardVoteStepCtaLabel, + wizardVoteStepTitle, + type VoteShortcut, + type WizardVoteEditScenario, +} from '@/lib/wizardVoteEstimate' + +type WizardExpectedVotesStepProps = { + actionSlug: string + municipalityId: number + municipalityName: string + municipalitySlug: string + initialExpectedVotes: VoteEstimateScenarioViewModel + currentScenario: WizardVoteEditScenario +} + +export const WizardExpectedVotesStep = ({ + actionSlug, + municipalityId, + municipalityName, + municipalitySlug, + initialExpectedVotes, + currentScenario, +}: WizardExpectedVotesStepProps) => { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [confirmed, setConfirmed] = useState(initialExpectedVotes) + const [draft, setDraft] = useState(() => + draftTextForVoteValue(initialExpectedVotes[currentScenario]), + ) + const [draftError, setDraftError] = useState(null) + const [saveError, setSaveError] = useState(null) + const [phase, setPhase] = useState<'editing' | 'saved'>('editing') + const [violationEditedScenario, setViolationEditedScenario] = + useState(null) + const previousScenarioRef = useRef(currentScenario) + const inputRef = useRef(null) + + useEffect(() => { + if (previousScenarioRef.current === currentScenario) return + setDraft(draftTextForVoteValue(confirmed[currentScenario])) + setDraftError(null) + setViolationEditedScenario(null) + previousScenarioRef.current = currentScenario + }, [confirmed, currentScenario]) + + const activeViolation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null + const showViolationBanner = + violationEditedScenario != null && + activeViolation != null && + activeViolation.violatingScenario === currentScenario + + const previousScenario = getPreviousWizardVoteScenario(currentScenario) + const previousHref = previousScenario + ? wizardActionHref(actionSlug, municipalitySlug, previousScenario) + : wizardActionHref(actionSlug) + + const handleShortcut = (shortcut: VoteShortcut) => { + const currentValue = parseWizardVoteDraft(draft) + const nextValue = applyVoteShortcut(currentValue, shortcut) + setDraft(draftTextForVoteValue(nextValue)) + setDraftError(null) + setSaveError(null) + inputRef.current?.focus() + } + + const handleConfirm = () => { + if (isPending) return + + const trimmed = draft.trim() + const parsed = trimmed ? parseWizardVoteDraft(draft) : null + if (trimmed && parsed === null) { + setDraftError(WIZARD_VOTES_INVALID_DRAFT_MESSAGE) + return + } + + const nextConfirmed = mergeWizardVoteEstimate(confirmed, currentScenario, parsed) + const violation = getWizardVoteViolation(nextConfirmed) + + if (violation) { + setConfirmed(nextConfirmed) + setViolationEditedScenario(currentScenario) + setSaveError(null) + if (violation.violatingScenario !== currentScenario) { + router.replace(wizardActionHref(actionSlug, municipalitySlug, violation.violatingScenario)) + } + return + } + + setConfirmed(nextConfirmed) + setViolationEditedScenario(null) + setSaveError(null) + setDraftError(null) + + const nextScenario = getNextWizardVoteScenario(currentScenario) + if (nextScenario) { + router.replace(wizardActionHref(actionSlug, municipalitySlug, nextScenario)) + return + } + + startTransition(async () => { + const { ok, payload } = await postCampaignJson( + MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + { + municipalityId, + expectedVotes: nextConfirmed, + }, + ) + + if (!ok || payload.status !== 'success') { + setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE) + return + } + + setConfirmed(payload.savedExpectedVotes) + setPhase('saved') + }) + } + + const handleReturnToEdited = () => { + if (!violationEditedScenario) return + router.replace(wizardActionHref(actionSlug, municipalitySlug, violationEditedScenario)) + setViolationEditedScenario(null) + } + + const currentValue = confirmed[currentScenario] + const nextScenario = getNextWizardVoteScenario(currentScenario) + const ctaLabel = + nextScenario == null ? wizardVoteFinalCtaLabel : wizardVoteStepCtaLabel(currentScenario) + + if (phase === 'saved') { + return ( + +
+

{WIZARD_VOTES_SAVED_MESSAGE}

+

{WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER}

+ +
+
+ ) + } + + return ( + +
+
+

+ Valor atual:{' '} + {currentValue == null ? 'não informado' : formatElectionNumber(currentValue)} +

+ + { + setDraft(event.currentTarget.value.replace(/\D/g, '')) + setDraftError(null) + setSaveError(null) + }} + onFocus={(event) => event.currentTarget.select()} + disabled={isPending} + className={cn( + 'h-auto min-h-14 border-input px-3 py-3 text-center text-2xl font-medium tabular-nums', + 'md:min-h-16 md:text-3xl', + )} + /> +
+ +
+ {VOTE_SHORTCUTS.map((shortcut) => ( + + ))} +
+ + {showViolationBanner && activeViolation ? ( + + + {activeViolation.message} + + + + ) : null} + + {draftError ? ( + + {draftError} + + ) : null} + + {saveError ? ( + + {saveError} + + ) : null} + +
+ {isPending ? 'Salvando votos estimados.' : null} +
+ + +
+
+ ) +} diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index da4f010c..df8a4df7 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -1,5 +1,8 @@ /** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */ +import type { WizardVoteEditScenario } from '@/lib/wizardVoteEstimate' +import { isWizardVoteEditScenario } from '@/lib/wizardVoteEstimate' + export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const export type CampaignWizardActionId = @@ -29,12 +32,21 @@ export const campaignActionEntryHref = (id: CampaignWizardActionId): string => export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const -export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): string => { +export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const + +export const wizardActionHref = ( + actionSlug: string, + municipalitySlug?: string, + scenario?: WizardVoteEditScenario, +): string => { const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}` if (!municipalitySlug) { return base } const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug }) + if (scenario) { + params.set(WIZARD_SCENARIO_QUERY_KEY, scenario) + } return `${base}?${params.toString()}` } @@ -45,3 +57,17 @@ export const parseWizardMunicipioParam = ( const trimmed = raw?.trim() return trimmed ? trimmed : undefined } + +export const resolveWizardScenarioParam = ( + value: string | string[] | undefined, +): { scenario: WizardVoteEditScenario; invalid: boolean } => { + const raw = Array.isArray(value) ? value[0] : value + const trimmed = raw?.trim() + if (!trimmed) { + return { scenario: 'central', invalid: false } + } + if (isWizardVoteEditScenario(trimmed)) { + return { scenario: trimmed, invalid: false } + } + return { scenario: 'central', invalid: true } +} diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index 3be59a0f..724ae89a 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -1,6 +1,6 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes' -/** Shared copy for UX-1 wizard municipality step (B60). */ +/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */ export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const @@ -9,9 +9,19 @@ export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.' as const -/** Placeholder until B61 ships vote adjustment. */ -const WIZARD_NEXT_STEP_VOTES_PLACEHOLDER = - 'Próximo passo: ajuste de votos estimados neste município.' as const +export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as const + +export const WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER = + 'Próximo passo deste fluxo (sinal, tendência ou resumo) em breve.' as const + +export const WIZARD_VOTES_SAVE_ERROR_MESSAGE = + 'Não foi possível salvar os votos estimados. Tente novamente.' as const + +export const WIZARD_VOTES_INVALID_DRAFT_MESSAGE = + 'Informe um número válido ou deixe em branco.' as const + +export const wizardVoteReturnToScenarioLabel = (scenarioLabel: string): string => + `Voltar para ${scenarioLabel.toLowerCase()}` const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const @@ -24,7 +34,7 @@ export const wizardNextStepTitle = (actionSlug: string): string => { export const wizardNextStepPlaceholder = (actionSlug: string): string => { if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) { - return WIZARD_NEXT_STEP_VOTES_PLACEHOLDER + return WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER } return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER } diff --git a/src/lib/wizardVoteEstimate.ts b/src/lib/wizardVoteEstimate.ts new file mode 100644 index 00000000..a8196de1 --- /dev/null +++ b/src/lib/wizardVoteEstimate.ts @@ -0,0 +1,145 @@ +import { formatElectionNumber } from '@/lib/electionFormat' +import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives' +import { + getVoteEstimateOrderViolation, + VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, + voteEstimateScenarioLabels, + type VoteEstimateScenario, + type VoteEstimateScenarioViewModel, +} from '@/lib/voteEstimate' + +/** Mesa ritual order: média → pessimista → otimista (≠ storage order pessimistic ≤ central ≤ optimistic). */ +export const WIZARD_VOTE_SCENARIO_EDIT_ORDER = [ + 'central', + 'pessimistic', + 'optimistic', +] as const satisfies readonly VoteEstimateScenario[] + +export type WizardVoteEditScenario = VoteEstimateScenario + +export type VoteShortcut = 'double' | '+50' | '-50' | '+100' | '-100' + +export const VOTE_SHORTCUTS: readonly VoteShortcut[] = [ + 'double', + '+50', + '-50', + '+100', + '-100', +] as const + +export const voteShortcutLabels: Record = { + double: '2×', + '+50': '+50', + '-50': '−50', + '+100': '+100', + '-100': '−100', +} + +export const isWizardVoteEditScenario = (value: string): value is WizardVoteEditScenario => + (WIZARD_VOTE_SCENARIO_EDIT_ORDER as readonly string[]).includes(value) + +export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut): number => { + const base = value ?? 0 + if (shortcut === 'double') { + if (base < 1) return base + return Math.min(base * 2, MAX_VOTE_COUNT) + } + + const delta = + shortcut === '+50' ? 50 : shortcut === '-50' ? -50 : shortcut === '+100' ? 100 : -100 + return Math.max(0, Math.min(base + delta, MAX_VOTE_COUNT)) +} + +export const parseWizardVoteDraft = (raw: string): number | null => { + const trimmed = raw.trim() + if (!trimmed) return null + const digitsOnly = trimmed.replace(/\D/g, '') + if (!digitsOnly) return null + const parsed = Number(digitsOnly) + if (!Number.isFinite(parsed)) return null + return Math.min(Math.trunc(parsed), MAX_VOTE_COUNT) +} + +export const draftTextForVoteValue = (value: number | null | undefined): string => + value == null ? '' : String(value) + +export const getNextWizardVoteScenario = ( + current: WizardVoteEditScenario, +): WizardVoteEditScenario | null => { + const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current) + if (index < 0 || index >= WIZARD_VOTE_SCENARIO_EDIT_ORDER.length - 1) return null + return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index + 1]! +} + +export const getPreviousWizardVoteScenario = ( + current: WizardVoteEditScenario, +): WizardVoteEditScenario | null => { + const index = WIZARD_VOTE_SCENARIO_EDIT_ORDER.indexOf(current) + if (index <= 0) return null + return WIZARD_VOTE_SCENARIO_EDIT_ORDER[index - 1]! +} + +export const wizardVoteStepTitle = (scenario: WizardVoteEditScenario): string => { + const label = voteEstimateScenarioLabels[scenario] + return `Qual a nova estimativa ${label.toLowerCase()}?` +} + +export const wizardVoteStepCtaLabel = (scenario: WizardVoteEditScenario): string => { + const label = voteEstimateScenarioLabels[scenario] + return `Ajustar estimativa ${label.toLowerCase()} →` +} + +export const wizardVoteFinalCtaLabel = 'Salvar estimativas →' as const + +export type WizardVoteViolation = { + violatingScenario: VoteEstimateScenario + message: string +} + +export const getWizardVoteViolation = ( + estimates: VoteEstimateScenarioViewModel, +): WizardVoteViolation | null => { + const violatingScenario = getVoteEstimateOrderViolation(estimates) + if (!violatingScenario) return null + + return { + violatingScenario, + message: buildWizardVoteViolationMessage(estimates, violatingScenario), + } +} + +const buildWizardVoteViolationMessage = ( + estimates: VoteEstimateScenarioViewModel, + violatingScenario: VoteEstimateScenario, +): string => { + const { pessimistic, central, optimistic } = estimates + + if ( + violatingScenario === 'central' && + pessimistic != null && + central != null && + pessimistic > central + ) { + return `${voteEstimateScenarioLabels.pessimistic} (${formatElectionNumber(pessimistic)}) não pode ser maior que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).` + } + + if ( + violatingScenario === 'optimistic' && + central != null && + optimistic != null && + optimistic < central + ) { + return `${voteEstimateScenarioLabels.optimistic} (${formatElectionNumber(optimistic)}) não pode ser menor que ${voteEstimateScenarioLabels.central.toLowerCase()} (${formatElectionNumber(central)}).` + } + + return VOTE_ESTIMATE_ORDER_ERROR_MESSAGE +} + +export const mergeWizardVoteEstimate = ( + base: VoteEstimateScenarioViewModel, + scenario: WizardVoteEditScenario, + value: number | null, +): VoteEstimateScenarioViewModel => ({ + ...base, + [scenario]: value, +}) diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 94c2f688..2b3a34c6 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -145,6 +145,57 @@ test.describe('Wizard — busca município (B60)', () => { await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/) await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible() - await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible() + await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible() + }) +}) + +test.describe('Wizard — ajuste de votos (B61)', () => { + test('confirming média advances to pessimista step', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu') + + await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({ + timeout: 15000, + }) + + const input = page.getByLabel(/Média em Cairu/i) + await input.fill('500') + await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click() + + await page.waitForURL(/cenario=pessimistic/) + await expect( + page.getByRole('heading', { name: /Qual a nova estimativa pessimista/i }), + ).toBeVisible() + }) + + test('incoherent pessimista shows warning and return link', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu') + + await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({ + timeout: 15000, + }) + + await page.getByLabel(/Média em Cairu/i).fill('200') + await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click() + await page.waitForURL(/cenario=pessimistic/) + + await page.getByLabel(/Pessimista em Cairu/i).fill('900') + await page.getByRole('button', { name: 'Ajustar estimativa pessimista →' }).click() + + await page.waitForURL(/cenario=central/) + const step = page.getByRole('main', { name: /Qual a nova estimativa média/i }) + await expect(step.getByRole('alert')).toContainText(/Pessimista/i) + await expect(step.getByRole('button', { name: /Voltar para pessimista/i })).toBeVisible() }) }) diff --git a/tests/unit/campaignActionRoutes.unit.spec.ts b/tests/unit/campaignActionRoutes.unit.spec.ts index 758c5794..600bec39 100644 --- a/tests/unit/campaignActionRoutes.unit.spec.ts +++ b/tests/unit/campaignActionRoutes.unit.spec.ts @@ -7,6 +7,7 @@ import { isCampaignWizardActionId, isCampaignWizardActionSlug, parseWizardMunicipioParam, + resolveWizardScenarioParam, wizardActionHref, } from '@/lib/campaignActionRoutes' @@ -34,6 +35,18 @@ describe('campaignActionRoutes', () => { expect(wizardActionHref('atualizar-votos', 'cairu')).toBe( `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`, ) + expect(wizardActionHref('atualizar-votos', 'cairu', 'pessimistic')).toBe( + `${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu&cenario=pessimistic`, + ) + }) + + it('resolves scenario search param with default and invalid flag', () => { + expect(resolveWizardScenarioParam(undefined)).toEqual({ scenario: 'central', invalid: false }) + expect(resolveWizardScenarioParam('optimistic')).toEqual({ + scenario: 'optimistic', + invalid: false, + }) + expect(resolveWizardScenarioParam('invalid')).toEqual({ scenario: 'central', invalid: true }) }) it('parses municipio search param', () => { diff --git a/tests/unit/wizardVoteEstimate.unit.spec.ts b/tests/unit/wizardVoteEstimate.unit.spec.ts new file mode 100644 index 00000000..ebef84d7 --- /dev/null +++ b/tests/unit/wizardVoteEstimate.unit.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives' +import { + applyVoteShortcut, + getNextWizardVoteScenario, + getPreviousWizardVoteScenario, + getWizardVoteViolation, + mergeWizardVoteEstimate, + parseWizardVoteDraft, + WIZARD_VOTE_SCENARIO_EDIT_ORDER, + wizardVoteStepCtaLabel, +} from '@/lib/wizardVoteEstimate' + +describe('applyVoteShortcut', () => { + it('doubles the current value', () => { + expect(applyVoteShortcut(150, 'double')).toBe(300) + }) + + it('no-ops double when value is below 1', () => { + expect(applyVoteShortcut(0, 'double')).toBe(0) + expect(applyVoteShortcut(null, 'double')).toBe(0) + }) + + it('clamps at MAX_VOTE_COUNT', () => { + expect(applyVoteShortcut(MAX_VOTE_COUNT - 10, '+50')).toBe(MAX_VOTE_COUNT) + expect(applyVoteShortcut(MAX_VOTE_COUNT, 'double')).toBe(MAX_VOTE_COUNT) + }) + + it('applies arithmetic shortcuts and floors at zero', () => { + expect(applyVoteShortcut(200, '+50')).toBe(250) + expect(applyVoteShortcut(200, '-50')).toBe(150) + expect(applyVoteShortcut(200, '+100')).toBe(300) + expect(applyVoteShortcut(40, '-100')).toBe(0) + }) +}) + +describe('wizard scenario navigation', () => { + it('walks média → pessimista → otimista', () => { + expect(WIZARD_VOTE_SCENARIO_EDIT_ORDER).toEqual(['central', 'pessimistic', 'optimistic']) + expect(getNextWizardVoteScenario('central')).toBe('pessimistic') + expect(getNextWizardVoteScenario('pessimistic')).toBe('optimistic') + expect(getNextWizardVoteScenario('optimistic')).toBeNull() + expect(getPreviousWizardVoteScenario('pessimistic')).toBe('central') + expect(getPreviousWizardVoteScenario('central')).toBeNull() + }) + + it('labels CTA per scenario', () => { + expect(wizardVoteStepCtaLabel('central')).toBe('Ajustar estimativa média →') + expect(wizardVoteStepCtaLabel('pessimistic')).toBe('Ajustar estimativa pessimista →') + expect(wizardVoteStepCtaLabel('optimistic')).toBe('Ajustar estimativa otimista →') + }) +}) + +describe('parseWizardVoteDraft', () => { + it('parses digits and empty as null', () => { + expect(parseWizardVoteDraft('')).toBeNull() + expect(parseWizardVoteDraft(' 1.200 ')).toBe(1200) + expect(parseWizardVoteDraft('abc')).toBeNull() + }) +}) + +describe('getWizardVoteViolation', () => { + it('returns null for ordered triples', () => { + expect(getWizardVoteViolation({ pessimistic: 100, central: 200, optimistic: 300 })).toBeNull() + }) + + it('flags pessimista above média with readable message', () => { + const violation = getWizardVoteViolation({ pessimistic: 300, central: 200, optimistic: 400 }) + expect(violation?.violatingScenario).toBe('central') + expect(violation?.message).toContain('Pessimista') + expect(violation?.message).toContain('média') + }) + + it('flags otimista below média', () => { + const violation = getWizardVoteViolation({ pessimistic: 100, central: 500, optimistic: 400 }) + expect(violation?.violatingScenario).toBe('optimistic') + expect(violation?.message).toContain('Otimista') + }) +}) + +describe('mergeWizardVoteEstimate', () => { + it('updates one scenario while keeping the rest', () => { + expect( + mergeWizardVoteEstimate({ pessimistic: 100, central: 200, optimistic: 300 }, 'central', 250), + ).toEqual({ pessimistic: 100, central: 250, optimistic: 300 }) + }) +}) From 96d9e136c31cca9b6c2fdaeab6b242463a91dc5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 01:17:30 -0300 Subject: [PATCH 2/3] docs(B61): record simplify deferrals in wizard plan. Capture post-simplify skips as explicit triggers instead of new roadmap debt. --- docs/plans/ajuste-votos-wizard.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/plans/ajuste-votos-wizard.md b/docs/plans/ajuste-votos-wizard.md index 7dfffe9f..d26bcffb 100644 --- a/docs/plans/ajuste-votos-wizard.md +++ b/docs/plans/ajuste-votos-wizard.md @@ -131,6 +131,25 @@ Componentes: - **Preview de cobertura E8 no rodapé do passo.** Revisitar se o CG pedir “isso fecha a meta?” durante o ajuste (sessão). +## Já resolvido no simplify (não reabrir) + +- Ramo morto em `buildWizardVoteViolationMessage` (cenário `central` com `optimistic < central` — `getVoteEstimateOrderViolation` já devolve `'optimistic'`). +- Estado duplicado de mensagem de violação (`violationReturn` → só `violationEditedScenario` + `activeViolation.message`). +- Banner de violação obsoleto ao voltar de cenário (limpa `violationEditedScenario` na troca). +- `parseWizardVoteDraft` duplicado em `handleConfirm`. +- Alias `WizardVoteEditScenario` = `VoteEstimateScenario`; `getWizardVoteViolation` lazy. +- Constante `MUNICIPALITY_EXPECTED_VOTES_ENDPOINT` compartilhada com `MunicipalityListExpectedVotesControl`. + +## Explicitamente fora (pós-/simplify, defer) + +| Achado | Gatilho para revisitar | +| --- | --- | +| Unificar `parseWizardVoteDraft` com `VoteEstimateScenarioInputs.parseScenarioInput` (pt-BR `"1.200"` difere) | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR | +| Extrair `SAVE_ERROR_MESSAGE` / copy de pending do wizard e da lista | 3º consumidor de POST JSON de célula **ou** helper `readCampaignJsonMutationResult` (precedente B32+) | +| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único | +| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON | +| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota | + ## Referências - [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) · [cenarios-estimativa-votos.md](cenarios-estimativa-votos.md) · `src/lib/voteEstimate.ts` · `MunicipalityListExpectedVotesControl.tsx` · `VoteEstimateScenarioInputs.tsx` · collection `Municipality` hook de ordem From 54dcf9e0f56e918b303c82802071be1d07baaeb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 01:18:33 -0300 Subject: [PATCH 3/3] chore: format B61 plan after simplify debt notes. --- docs/plans/ajuste-votos-wizard.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plans/ajuste-votos-wizard.md b/docs/plans/ajuste-votos-wizard.md index d26bcffb..04031189 100644 --- a/docs/plans/ajuste-votos-wizard.md +++ b/docs/plans/ajuste-votos-wizard.md @@ -142,13 +142,13 @@ Componentes: ## Explicitamente fora (pós-/simplify, defer) -| Achado | Gatilho para revisitar | -| --- | --- | -| Unificar `parseWizardVoteDraft` com `VoteEstimateScenarioInputs.parseScenarioInput` (pt-BR `"1.200"` difere) | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR | -| Extrair `SAVE_ERROR_MESSAGE` / copy de pending do wizard e da lista | 3º consumidor de POST JSON de célula **ou** helper `readCampaignJsonMutationResult` (precedente B32+) | -| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único | -| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON | -| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota | +| Achado | Gatilho para revisitar | +| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Unificar `parseWizardVoteDraft` com `VoteEstimateScenarioInputs.parseScenarioInput` (pt-BR `"1.200"` difere) | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR | +| Extrair `SAVE_ERROR_MESSAGE` / copy de pending do wizard e da lista | 3º consumidor de POST JSON de célula **ou** helper `readCampaignJsonMutationResult` (precedente B32+) | +| Validar body do wizard com `voteEstimateScenarioFieldsSchema` no client | Gravação por cenário (write parcial) em vez do batch único | +| Helper genérico `readCampaignJsonMutationResult` | 3ª rota/painel wizard com o mesmo shape de resposta JSON | +| Generalizar `parseWizardMunicipioParam` / `resolveWizardScenarioParam` num parser de query do wizard | B63/B64 reutilizarem `?cenario=` ou outro param de passo na mesma rota | ## Referências