B77: replace vote wizard ?cenario= ritual with single-screen editor.
Three scenarios on one step with confirm-only validation, legacy URL redirect, and updated tests/docs.
This commit is contained in:
parent
791c777820
commit
f2bdb1ce3c
15 changed files with 253 additions and 343 deletions
|
|
@ -7,9 +7,9 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
|
||||
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B78 sobre chrome único.
|
||||
- **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`.
|
||||
- **B77 registrado (2026-07-30):** simplificar wizard ajuste de votos — três cenários numa tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm com highlight; substitui ritual `?cenario=` de B61/B76. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md).
|
||||
- **B77 entregue em código (2026-07-30):** wizard ajuste de votos numa tela — `WizardExpectedVotesStep` substitui o ritual `?cenario=` de B61/B76 por editor único (strip + 3 inputs compact + atalhos no cenário focado); coerência `pessimistic ≤ central ≤ optimistic` só no confirm (`getWizardVoteViolation` + `getWizardVoteViolationHighlights`); `?cenario=` legado → redirect canônico; órfãos `wizardVoteStepTitle`/`mergeWizardVoteEstimate`/etc. removidos. Unit `wizardExpectedVotesStep`/`wizardVoteEstimate`; e2e B61 atualizado. Sem migration. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). Desbloqueia wiring A1 com menos atrito antes de **B63**/**B64**.
|
||||
- **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md).
|
||||
- **B66 entregue em código (2026-07-30):** modo focado da busca no **focus** (emenda B47) — `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook; overlap B68 (sugestões no focus vazio). **Nesta entrega:** `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions com grid `0fr/1fr` + opacity 220ms (`motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação. Sem migration. Plano: [`modo-focado-busca-no-focus.md`](../../../docs/plans/modo-focado-busca-no-focus.md).
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
|
||||
|
||||
**Recently resolved (2026-07-30):** **B77** Simplificar wizard ajuste de votos — `WizardExpectedVotesStep` reescrito: três cenários numa tela (`VoteEstimateScenarioInputs` compact + strip + atalhos no foco); validação `pessimistic ≤ central ≤ optimistic` só no confirm com `Alert` + highlight do par conflitante (`getWizardVoteViolationHighlights`); POST batch inalterado (`MUNICIPALITY_EXPECTED_VOTES_ENDPOINT`); `?cenario=` legado redireciona para URL canônica sem param (`hasWizardScenarioParam` + `wizardActionHref` sem 3º arg); órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignWizardCopy`. `VoteEstimateScenarioInputs` compact ganhou `activeScenario`/`onFocusScenario`/`errorScenarios`/`disabled` (lista inalterada). Sem migration. Plano: [`docs/plans/simplificar-ajuste-votos-wizard.md`](docs/plans/simplificar-ajuste-votos-wizard.md).
|
||||
|
||||
**Recently resolved (2026-07-30):** **B71** Polimento visual dos resultados de busca no Início — `src/lib/homeSearchUi.ts` centraliza bleed (`-mx-4 px-4 md:-mx-6 md:px-6`), heading discreto (`text-xs` sentence-case) e `ul list-none`; `HomeSearchHitRow` pinta hover/focus na faixa full-bleed; todos os `HomeSearch*Group` alinhados. Sem migration/action/contrato JSON. Plano: [`docs/plans/polimento-resultados-busca-inicio.md`](docs/plans/polimento-resultados-busca-inicio.md).
|
||||
|
||||
**Recently resolved (2026-07-30):** **B69** Simplificar modelo de liderança — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration `20260730_043306_simplify_leadership_fields` + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; `?sector=` legado canonicaliza fora). **Decisão de produto 2026-07-30 reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (SindMed com várias lideranças); vertical `/campanha/organizacoes` e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do `migrate:create` trava sem TTY — gerar drops e adds em passadas separadas (`CI=1`) e fundir à mão foi o caminho; o `NOT NULL` foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-lideranca.md`](docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70**.
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ Responsável: —
|
|||
|
||||
~~Jump+warning ao confirmar pessimista `> média` quebrava: `useEffect` limpava `violationEditedScenario` na troca de `?cenario=`.~~ **Corrigido em B76** — preservar estado no jump ao cenário violado; limpar ao editar draft/atalho.
|
||||
|
||||
### Emenda UX (2026-07-30 → **B77**)
|
||||
### Emenda UX (2026-07-30 → ~~B77~~ ✓)
|
||||
|
||||
Ritual de três passos `?cenario=` será **substituído** por uma tela com os três inputs + strip (como popover da lista); validação inline no confirm. Ver [simplificar-ajuste-votos-wizard.md](simplificar-ajuste-votos-wizard.md).
|
||||
Ritual de três passos `?cenario=` **substituído** por uma tela com os três inputs + strip (como popover da lista); validação inline no confirm. Ver [simplificar-ajuste-votos-wizard.md](simplificar-ajuste-votos-wizard.md).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
# Simplificar ajuste de votos no wizard (três cenários numa tela)
|
||||
|
||||
Status: rascunho
|
||||
Status: **entregue** (2026-07-30)
|
||||
Atualizado em: 2026-07-30
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B77 — UX-1 wizards)
|
||||
Impeccable: B — encaixe em `WizardExpectedVotesStep` existente; espelha o popover da lista de municípios
|
||||
Appetite: ~0,75–1 dia eng; substituir ritual `?cenario=` por editor único + confirm; reuso de strip/inputs/atalhos; sem migration
|
||||
Responsável: —
|
||||
|
||||
## As-built (entrega)
|
||||
|
||||
- `WizardExpectedVotesStep`: uma tela com `VoteEstimateScenarioInputs` (`variant="compact"`) + atalhos no cenário focado + CTA único `Salvar estimativas →`; violação inline no confirm (`Alert` + `aria-invalid` no par conflitante via `getWizardVoteViolationHighlights`).
|
||||
- `VoteEstimateScenarioInputs` compact ganhou `activeScenario`, `onFocusScenario`, `errorScenarios`, `disabled` (lista inalterada — props opcionais).
|
||||
- `wizardVoteEstimate.ts`: novo `getWizardVoteViolationHighlights`; removidos helpers do ritual `?cenario=` (`getNextWizardVoteScenario`, `wizardVoteStepTitle`, …).
|
||||
- `campaignActionRoutes.ts`: `wizardActionHref` sem 3º arg `cenario`; `hasWizardScenarioParam` + redirect canônico em `acoes/[slug]/page.tsx`.
|
||||
- Testes: `wizardExpectedVotesStep.unit.spec.tsx`, `wizardVoteEstimate.unit.spec.ts`, `campaignActionRoutes.unit.spec.ts`, e2e `campaignHomeActions` (B61/B77).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (Clarity under pressure; Feel the action) / `DESIGN.md` · referência visual = `MunicipalityListExpectedVotesControl` + `VoteEstimateScenarioInputs` (`variant="compact"`) · tema `campaign`.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,9 +5,9 @@ import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/Wizar
|
|||
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
|
||||
import {
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
hasWizardScenarioParam,
|
||||
isCampaignWizardActionSlug,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardScenarioParam,
|
||||
WIZARD_MUNICIPIO_QUERY_KEY,
|
||||
WIZARD_SCENARIO_QUERY_KEY,
|
||||
wizardActionHref,
|
||||
|
|
@ -58,11 +58,8 @@ export default async function CampaignActionWizardPage({
|
|||
}
|
||||
|
||||
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
|
||||
const { scenario, invalid } = resolveWizardScenarioParam(
|
||||
resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY],
|
||||
)
|
||||
if (invalid) {
|
||||
redirect(wizardActionHref(slug, municipalitySlug, 'central'))
|
||||
if (hasWizardScenarioParam(resolvedSearchParams[WIZARD_SCENARIO_QUERY_KEY])) {
|
||||
redirect(wizardActionHref(slug, municipalitySlug))
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -72,7 +69,6 @@ export default async function CampaignActionWizardPage({
|
|||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
|
||||
currentScenario={scenario}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
import { 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 { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
|
||||
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,
|
||||
wizardFlowTitleForSlug,
|
||||
wizardVoteReturnToScenarioLabel,
|
||||
wizardNextStepTitle,
|
||||
} from '@/lib/campaignWizardCopy'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { voteEstimateScenarioLabels, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import { type VoteEstimateScenario, type VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import {
|
||||
applyVoteShortcut,
|
||||
draftTextForVoteValue,
|
||||
getNextWizardVoteScenario,
|
||||
getPreviousWizardVoteScenario,
|
||||
getWizardVoteViolation,
|
||||
mergeWizardVoteEstimate,
|
||||
parseWizardVoteDraft,
|
||||
getWizardVoteViolationHighlights,
|
||||
VOTE_SHORTCUTS,
|
||||
voteShortcutLabels,
|
||||
wizardVoteFinalCtaLabel,
|
||||
wizardVoteStepCtaLabel,
|
||||
wizardVoteStepTitle,
|
||||
type VoteShortcut,
|
||||
type WizardVoteEditScenario,
|
||||
} from '@/lib/wizardVoteEstimate'
|
||||
|
||||
type WizardExpectedVotesStepProps = {
|
||||
|
|
@ -50,111 +39,60 @@ type WizardExpectedVotesStepProps = {
|
|||
municipalityName: string
|
||||
municipalitySlug: string
|
||||
initialExpectedVotes: VoteEstimateScenarioViewModel
|
||||
currentScenario: WizardVoteEditScenario
|
||||
}
|
||||
|
||||
export const WizardExpectedVotesStep = ({
|
||||
actionSlug,
|
||||
municipalityId,
|
||||
municipalityName,
|
||||
municipalitySlug,
|
||||
municipalitySlug: _municipalitySlug,
|
||||
initialExpectedVotes,
|
||||
currentScenario,
|
||||
}: WizardExpectedVotesStepProps) => {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [confirmed, setConfirmed] = useState<VoteEstimateScenarioViewModel>(initialExpectedVotes)
|
||||
const [draft, setDraft] = useState(() =>
|
||||
draftTextForVoteValue(initialExpectedVotes[currentScenario]),
|
||||
const [estimates, setEstimates] = useState(initialExpectedVotes)
|
||||
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
|
||||
const [violationMessage, setViolationMessage] = useState<string | null>(null)
|
||||
const [highlightScenarios, setHighlightScenarios] = useState<ReadonlySet<VoteEstimateScenario>>(
|
||||
new Set(),
|
||||
)
|
||||
const [draftError, setDraftError] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<'editing' | 'saved'>('editing')
|
||||
const [violationEditedScenario, setViolationEditedScenario] =
|
||||
useState<WizardVoteEditScenario | null>(null)
|
||||
const previousScenarioRef = useRef(currentScenario)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousScenarioRef.current === currentScenario) return
|
||||
const clearViolationState = () => {
|
||||
setViolationMessage(null)
|
||||
setHighlightScenarios(new Set())
|
||||
}
|
||||
|
||||
setDraft(draftTextForVoteValue(confirmed[currentScenario]))
|
||||
setDraftError(null)
|
||||
|
||||
const violation = violationEditedScenario != null ? getWizardVoteViolation(confirmed) : null
|
||||
const keepViolationState =
|
||||
violationEditedScenario != null &&
|
||||
violation != null &&
|
||||
violation.violatingScenario === currentScenario
|
||||
|
||||
if (!keepViolationState) {
|
||||
setViolationEditedScenario(null)
|
||||
}
|
||||
|
||||
previousScenarioRef.current = currentScenario
|
||||
}, [confirmed, currentScenario, violationEditedScenario])
|
||||
|
||||
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 handleValuesChange = (next: VoteEstimateScenarioViewModel) => {
|
||||
setEstimates(next)
|
||||
clearViolationState()
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const handleShortcut = (shortcut: VoteShortcut) => {
|
||||
const currentValue = parseWizardVoteDraft(draft)
|
||||
const nextValue = applyVoteShortcut(currentValue, shortcut)
|
||||
setDraft(draftTextForVoteValue(nextValue))
|
||||
setDraftError(null)
|
||||
setSaveError(null)
|
||||
setViolationEditedScenario(null)
|
||||
inputRef.current?.focus()
|
||||
const nextValue = applyVoteShortcut(estimates[focusedScenario], shortcut)
|
||||
handleValuesChange({ ...estimates, [focusedScenario]: nextValue })
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const violation = getWizardVoteViolation(estimates)
|
||||
if (violation) {
|
||||
setConfirmed(nextConfirmed)
|
||||
setViolationEditedScenario(currentScenario)
|
||||
setViolationMessage(violation.message)
|
||||
setHighlightScenarios(new Set(getWizardVoteViolationHighlights(estimates)))
|
||||
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
|
||||
}
|
||||
clearViolationState()
|
||||
|
||||
startTransition(async () => {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListExpectedVotesResponse>(
|
||||
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
|
||||
{
|
||||
municipalityId,
|
||||
expectedVotes: nextConfirmed,
|
||||
expectedVotes: estimates,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -163,22 +101,11 @@ export const WizardExpectedVotesStep = ({
|
|||
return
|
||||
}
|
||||
|
||||
setConfirmed(payload.savedExpectedVotes)
|
||||
setEstimates(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 (
|
||||
<CampaignWizardShell
|
||||
|
|
@ -203,9 +130,9 @@ export const WizardExpectedVotesStep = ({
|
|||
return (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle={wizardVoteStepTitle(currentScenario)}
|
||||
stepTitle={wizardNextStepTitle(actionSlug)}
|
||||
isEntryStep={false}
|
||||
previousHref={previousHref}
|
||||
previousHref={wizardActionHref(actionSlug)}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
>
|
||||
|
|
@ -214,35 +141,18 @@ export const WizardExpectedVotesStep = ({
|
|||
aria-busy={isPending}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Valor atual:{' '}
|
||||
{currentValue == null ? 'não informado' : formatElectionNumber(currentValue)}
|
||||
</p>
|
||||
<label htmlFor="wizard-expected-votes-input" className="sr-only">
|
||||
{voteEstimateScenarioLabels[currentScenario]} em {municipalityName}
|
||||
</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="wizard-expected-votes-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.currentTarget.value.replace(/\D/g, ''))
|
||||
setDraftError(null)
|
||||
setSaveError(null)
|
||||
setViolationEditedScenario(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',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<VoteEstimateScenarioInputs
|
||||
fieldPrefix="expectedVotes"
|
||||
values={estimates}
|
||||
idPrefix={`wizard-expected-votes-${municipalityId}`}
|
||||
variant="compact"
|
||||
autoFocusScenario="central"
|
||||
activeScenario={focusedScenario}
|
||||
onFocusScenario={setFocusedScenario}
|
||||
errorScenarios={highlightScenarios}
|
||||
disabled={isPending}
|
||||
onValuesChange={handleValuesChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VOTE_SHORTCUTS.map((shortcut) => (
|
||||
|
|
@ -259,27 +169,9 @@ export const WizardExpectedVotesStep = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{showViolationBanner && activeViolation ? (
|
||||
{violationMessage ? (
|
||||
<Alert variant="pending" className="border-estimate-pending">
|
||||
<AlertDescription className="flex flex-col gap-2 text-sm">
|
||||
<span>{activeViolation.message}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-sm"
|
||||
onClick={handleReturnToEdited}
|
||||
>
|
||||
{wizardVoteReturnToScenarioLabel(
|
||||
voteEstimateScenarioLabels[violationEditedScenario],
|
||||
)}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{draftError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="text-sm">{draftError}</AlertDescription>
|
||||
<AlertDescription className="text-sm">{violationMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
|
|
@ -305,7 +197,7 @@ export const WizardExpectedVotesStep = ({
|
|||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
ctaLabel
|
||||
wizardVoteFinalCtaLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ type VoteEstimateScenarioInputsProps = {
|
|||
idPrefix: string
|
||||
variant?: 'labeled' | 'compact'
|
||||
autoFocusScenario?: VoteEstimateScenario
|
||||
activeScenario?: VoteEstimateScenario
|
||||
onFocusScenario?: (scenario: VoteEstimateScenario) => void
|
||||
errorScenarios?: ReadonlySet<VoteEstimateScenario>
|
||||
disabled?: boolean
|
||||
onValuesChange?: (values: VoteEstimateScenarioViewModel) => void
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +70,10 @@ const CompactVoteEstimateScenarioInputs = ({
|
|||
values,
|
||||
idPrefix,
|
||||
autoFocusScenario,
|
||||
activeScenario = 'central',
|
||||
onFocusScenario,
|
||||
errorScenarios,
|
||||
disabled = false,
|
||||
onValuesChange,
|
||||
}: Omit<VoteEstimateScenarioInputsProps, 'variant'> & {
|
||||
onValuesChange: (values: VoteEstimateScenarioViewModel) => void
|
||||
|
|
@ -92,6 +100,7 @@ const CompactVoteEstimateScenarioInputs = ({
|
|||
<div className="flex flex-col gap-2.5">
|
||||
<VoteEstimateScenarioStrip
|
||||
values={values}
|
||||
activeScenario={activeScenario}
|
||||
labelMode="none"
|
||||
markerMode="active-only"
|
||||
stretch
|
||||
|
|
@ -106,15 +115,22 @@ const CompactVoteEstimateScenarioInputs = ({
|
|||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocusScenario === scenario}
|
||||
disabled={disabled}
|
||||
value={drafts[scenario]}
|
||||
onChange={(event) => handleDraftChange(scenario, event.currentTarget.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onFocus={(event) => {
|
||||
onFocusScenario?.(scenario)
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
aria-label={voteEstimateScenarioLabels[scenario]}
|
||||
aria-invalid={errorScenarios?.has(scenario) ? true : undefined}
|
||||
className={cn(
|
||||
compactInputClassName,
|
||||
scenario === 'central'
|
||||
? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15'
|
||||
: 'bg-muted/30',
|
||||
errorScenarios?.has(scenario)
|
||||
? 'border-destructive ring-1 ring-destructive/30'
|
||||
: scenario === 'central'
|
||||
? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15'
|
||||
: 'bg-muted/30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -129,6 +145,10 @@ export const VoteEstimateScenarioInputs = ({
|
|||
idPrefix,
|
||||
variant = 'labeled',
|
||||
autoFocusScenario,
|
||||
activeScenario,
|
||||
onFocusScenario,
|
||||
errorScenarios,
|
||||
disabled,
|
||||
onValuesChange,
|
||||
}: VoteEstimateScenarioInputsProps) => {
|
||||
if (variant === 'compact') {
|
||||
|
|
@ -141,6 +161,10 @@ export const VoteEstimateScenarioInputs = ({
|
|||
values={values}
|
||||
idPrefix={idPrefix}
|
||||
autoFocusScenario={autoFocusScenario}
|
||||
activeScenario={activeScenario}
|
||||
onFocusScenario={onFocusScenario}
|
||||
errorScenarios={errorScenarios}
|
||||
disabled={disabled}
|
||||
onValuesChange={onValuesChange}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
/** 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 =
|
||||
|
|
@ -42,21 +39,15 @@ export const campaignActionEntryHref = (id: CampaignWizardActionId): string =>
|
|||
|
||||
export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
|
||||
|
||||
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
|
||||
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
|
||||
|
||||
export const wizardActionHref = (
|
||||
actionSlug: string,
|
||||
municipalitySlug?: string,
|
||||
scenario?: WizardVoteEditScenario,
|
||||
): string => {
|
||||
export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): 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()}`
|
||||
}
|
||||
|
||||
|
|
@ -68,16 +59,7 @@ export const parseWizardMunicipioParam = (
|
|||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
export const resolveWizardScenarioParam = (
|
||||
value: string | string[] | undefined,
|
||||
): { scenario: WizardVoteEditScenario; invalid: boolean } => {
|
||||
export const hasWizardScenarioParam = (value: string | string[] | undefined): 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 }
|
||||
return raw?.trim() ? true : false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,12 +34,6 @@ export const WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER =
|
|||
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
|
||||
|
||||
export const wizardNextStepTitle = (actionSlug: string): string => {
|
||||
|
|
|
|||
|
|
@ -8,15 +8,6 @@ import {
|
|||
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[] = [
|
||||
|
|
@ -35,9 +26,6 @@ export const voteShortcutLabels: Record<VoteShortcut, string> = {
|
|||
'-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') {
|
||||
|
|
@ -60,35 +48,6 @@ export const parseWizardVoteDraft = (raw: string): number | 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 = {
|
||||
|
|
@ -108,6 +67,36 @@ export const getWizardVoteViolation = (
|
|||
}
|
||||
}
|
||||
|
||||
export const getWizardVoteViolationHighlights = (
|
||||
estimates: VoteEstimateScenarioViewModel,
|
||||
): VoteEstimateScenario[] => {
|
||||
const violation = getWizardVoteViolation(estimates)
|
||||
if (!violation) return []
|
||||
|
||||
const { violatingScenario } = violation
|
||||
const { pessimistic, central, optimistic } = estimates
|
||||
|
||||
if (
|
||||
violatingScenario === 'central' &&
|
||||
pessimistic != null &&
|
||||
central != null &&
|
||||
pessimistic > central
|
||||
) {
|
||||
return ['pessimistic', 'central']
|
||||
}
|
||||
|
||||
if (
|
||||
violatingScenario === 'optimistic' &&
|
||||
central != null &&
|
||||
optimistic != null &&
|
||||
optimistic < central
|
||||
) {
|
||||
return ['optimistic', 'central']
|
||||
}
|
||||
|
||||
return [violatingScenario]
|
||||
}
|
||||
|
||||
const buildWizardVoteViolationMessage = (
|
||||
estimates: VoteEstimateScenarioViewModel,
|
||||
violatingScenario: VoteEstimateScenario,
|
||||
|
|
@ -134,12 +123,3 @@ const buildWizardVoteViolationMessage = (
|
|||
|
||||
return VOTE_ESTIMATE_ORDER_ERROR_MESSAGE
|
||||
}
|
||||
|
||||
export const mergeWizardVoteEstimate = (
|
||||
base: VoteEstimateScenarioViewModel,
|
||||
scenario: WizardVoteEditScenario,
|
||||
value: number | null,
|
||||
): VoteEstimateScenarioViewModel => ({
|
||||
...base,
|
||||
[scenario]: value,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
|
|
@ -168,12 +170,18 @@ 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: /Qual a nova estimativa média/i })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Wizard — ajuste de votos (B61)', () => {
|
||||
test('confirming média advances to pessimista step', async ({ campaign, page }) => {
|
||||
test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
|
||||
const wizardVoteStep = (page: Page) =>
|
||||
page.getByRole('main', { name: /Ajustar votos estimados/i })
|
||||
|
||||
test('shows three scenario inputs and saves without cenario param', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
|
|
@ -182,21 +190,27 @@ test.describe('Wizard — ajuste de votos (B61)', () => {
|
|||
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({
|
||||
const step = wizardVoteStep(page)
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(step.getByRole('textbox', { name: 'Pessimista' })).toBeVisible()
|
||||
await expect(step.getByRole('textbox', { name: 'Média' })).toBeVisible()
|
||||
await expect(step.getByRole('textbox', { name: 'Otimista' })).toBeVisible()
|
||||
|
||||
const input = page.getByLabel(/Média em Cairu/i)
|
||||
await input.fill('500')
|
||||
await page.getByRole('button', { name: 'Ajustar estimativa média →' }).click()
|
||||
await step.getByRole('textbox', { name: 'Pessimista' }).fill('150')
|
||||
await step.getByRole('textbox', { name: 'Média' }).fill('250')
|
||||
await step.getByRole('textbox', { name: 'Otimista' }).fill('350')
|
||||
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
|
||||
|
||||
await page.waitForURL(/cenario=pessimistic/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Qual a nova estimativa pessimista/i }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible({ timeout: 15000 })
|
||||
expect(page.url()).not.toContain('cenario=')
|
||||
})
|
||||
|
||||
test('incoherent pessimista shows warning and return link', async ({ campaign, page }) => {
|
||||
test('incoherent estimates show inline warning without changing URL', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geral'),
|
||||
|
|
@ -205,20 +219,31 @@ test.describe('Wizard — ajuste de votos (B61)', () => {
|
|||
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({
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).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/)
|
||||
const step = wizardVoteStep(page)
|
||||
await step.getByRole('textbox', { name: 'Pessimista' }).fill('900')
|
||||
await step.getByRole('textbox', { name: 'Média' }).fill('100')
|
||||
await page.getByRole('button', { name: 'Salvar estimativas →' }).click()
|
||||
|
||||
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()
|
||||
expect(page.url()).not.toContain('cenario=')
|
||||
})
|
||||
|
||||
test('legacy cenario param redirects to canonical URL', 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&cenario=pessimistic')
|
||||
|
||||
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu$/)
|
||||
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import {
|
|||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
campaignActionEntryHref,
|
||||
campaignWizardActionIdForSlug,
|
||||
hasWizardScenarioParam,
|
||||
isCampaignWizardActionId,
|
||||
isCampaignWizardActionSlug,
|
||||
parseWizardMunicipioParam,
|
||||
resolveWizardScenarioParam,
|
||||
wizardActionHref,
|
||||
} from '@/lib/campaignActionRoutes'
|
||||
|
||||
|
|
@ -36,18 +36,12 @@ 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('detects legacy scenario search param', () => {
|
||||
expect(hasWizardScenarioParam(undefined)).toBe(false)
|
||||
expect(hasWizardScenarioParam('central')).toBe(true)
|
||||
expect(hasWizardScenarioParam(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('parses municipio search param', () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
|
||||
const replace = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
}))
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
|
||||
|
|
@ -40,43 +35,70 @@ const renderVotesStep = (props: React.ComponentProps<typeof WizardExpectedVotesS
|
|||
describe('WizardExpectedVotesStep', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
replace.mockClear()
|
||||
vi.mocked(postCampaignJson).mockReset()
|
||||
})
|
||||
|
||||
it('shows violation banner on média immediately after pessimista breaks order', () => {
|
||||
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
|
||||
it('applies shortcuts to the focused scenario', () => {
|
||||
renderVotesStep({ ...defaultProps })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
|
||||
fireEvent.focus(screen.getByLabelText('Pessimista'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '2×' }))
|
||||
|
||||
expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central'))
|
||||
expect((screen.getByLabelText('Pessimista') as HTMLInputElement).value).toBe('200')
|
||||
expect((screen.getByLabelText('Média') as HTMLInputElement).value).toBe('200')
|
||||
})
|
||||
|
||||
rerender(
|
||||
<CampaignWizardChromeProvider>
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
|
||||
</CampaignWizardChromeProvider>,
|
||||
)
|
||||
it('shows violation alert and highlights inputs without navigation', () => {
|
||||
renderVotesStep({ ...defaultProps })
|
||||
|
||||
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
|
||||
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
|
||||
|
||||
const step = screen.getByRole('main', { name: /Ajustar votos estimados/i })
|
||||
expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i)
|
||||
expect(within(step).getByRole('button', { name: /Voltar para pessimista/i })).toBeTruthy()
|
||||
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBe('true')
|
||||
expect(screen.getByLabelText('Média').getAttribute('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('clears violation banner when média draft is edited', () => {
|
||||
const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' })
|
||||
it('clears violation when a draft is edited', () => {
|
||||
renderVotesStep({ ...defaultProps })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' }))
|
||||
rerender(
|
||||
<CampaignWizardChromeProvider>
|
||||
<WizardExpectedVotesStep {...defaultProps} currentScenario="central" />
|
||||
</CampaignWizardChromeProvider>,
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '900' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
|
||||
|
||||
const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i })
|
||||
const step = screen.getByRole('main', { name: /Ajustar votos estimados/i })
|
||||
expect(within(step).getByRole('alert')).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Média em Cairu/i), { target: { value: '250' } })
|
||||
fireEvent.change(screen.getByLabelText('Média'), { target: { value: '950' } })
|
||||
expect(within(step).queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByLabelText('Pessimista').getAttribute('aria-invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('posts batch expected votes when estimates are coherent', async () => {
|
||||
vi.mocked(postCampaignJson).mockResolvedValue({
|
||||
ok: true,
|
||||
payload: {
|
||||
status: 'success',
|
||||
savedExpectedVotes: { pessimistic: 150, central: 250, optimistic: 350 },
|
||||
},
|
||||
})
|
||||
|
||||
renderVotesStep({ ...defaultProps })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Pessimista'), { target: { value: '150' } })
|
||||
fireEvent.change(screen.getByLabelText('Média'), { target: { value: '250' } })
|
||||
fireEvent.change(screen.getByLabelText('Otimista'), { target: { value: '350' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar estimativas →' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postCampaignJson).toHaveBeenCalledWith('/campanha/municipios/expected-votes', {
|
||||
municipalityId: 1,
|
||||
expectedVotes: { pessimistic: 150, central: 250, optimistic: 350 },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status').textContent).toMatch(/Votos estimados atualizados/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@ import { describe, expect, it } from 'vitest'
|
|||
import { MAX_VOTE_COUNT } from '@/lib/schemas/primitives'
|
||||
import {
|
||||
applyVoteShortcut,
|
||||
getNextWizardVoteScenario,
|
||||
getPreviousWizardVoteScenario,
|
||||
getWizardVoteViolation,
|
||||
mergeWizardVoteEstimate,
|
||||
getWizardVoteViolationHighlights,
|
||||
parseWizardVoteDraft,
|
||||
WIZARD_VOTE_SCENARIO_EDIT_ORDER,
|
||||
wizardVoteStepCtaLabel,
|
||||
} from '@/lib/wizardVoteEstimate'
|
||||
|
||||
describe('applyVoteShortcut', () => {
|
||||
|
|
@ -35,23 +31,6 @@ describe('applyVoteShortcut', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
|
@ -79,10 +58,22 @@ describe('getWizardVoteViolation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('mergeWizardVoteEstimate', () => {
|
||||
it('updates one scenario while keeping the rest', () => {
|
||||
describe('getWizardVoteViolationHighlights', () => {
|
||||
it('highlights pessimista and média when pessimista is above média', () => {
|
||||
expect(
|
||||
mergeWizardVoteEstimate({ pessimistic: 100, central: 200, optimistic: 300 }, 'central', 250),
|
||||
).toEqual({ pessimistic: 100, central: 250, optimistic: 300 })
|
||||
getWizardVoteViolationHighlights({ pessimistic: 300, central: 200, optimistic: 400 }),
|
||||
).toEqual(['pessimistic', 'central'])
|
||||
})
|
||||
|
||||
it('highlights otimista and média when otimista is below média', () => {
|
||||
expect(
|
||||
getWizardVoteViolationHighlights({ pessimistic: 100, central: 500, optimistic: 400 }),
|
||||
).toEqual(['optimistic', 'central'])
|
||||
})
|
||||
|
||||
it('returns empty when estimates are ordered', () => {
|
||||
expect(
|
||||
getWizardVoteViolationHighlights({ pessimistic: 100, central: 200, optimistic: 300 }),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue