diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index efe29c9d..8268dfb1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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 `
` (`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 `
` (`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). diff --git a/AGENTS.md b/AGENTS.md index b503d17f..4e9392a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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**. diff --git a/docs/plans/ajuste-votos-wizard.md b/docs/plans/ajuste-votos-wizard.md index 1edbc6e7..ed6b86e1 100644 --- a/docs/plans/ajuste-votos-wizard.md +++ b/docs/plans/ajuste-votos-wizard.md @@ -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) @@ -144,19 +144,22 @@ Componentes: - 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`. +- `parseWizardVoteDraft` removido (B77+); parsing fica só em `VoteEstimateScenarioInputs.parseScenarioInput`. - Alias `WizardVoteEditScenario` = `VoteEstimateScenario`; `getWizardVoteViolation` lazy. - Constante `MUNICIPALITY_EXPECTED_VOTES_ENDPOINT` compartilhada com `MunicipalityListExpectedVotesControl`. +- **B77+:** `getWizardVoteViolation` devolve `highlightScenarios` num único pass; `MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE` compartilhada wizard+lista; prop `municipalitySlug` morta no passo; `hasWizardScenarioParam` delega a `parseWizardMunicipioParam`; CTA `WIZARD_VOTES_FINAL_CTA_LABEL` em `campaignWizardCopy.ts`. ## 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 `VoteEstimateScenarioInputs.parseScenarioInput` com clamp `MAX_VOTE_COUNT` no wizard | 3ª superfície de input de votos **ou** passe repo-wide de parsing numérico pt-BR | +| Extrair shell `ExpectedVotesEditor` (wizard + lista) | `/simplify` medir duplicação >~60 linhas **e** políticas de save alinhadas (hoje wizard = confirm, lista = autosave) | +| Validar ordem pessimista/média/otimista no client na lista (popover) | Produto pedir feedback inline antes do autosave **ou** 3º editor inline de votos estimados | +| `useCampaignCellAutosave` no wizard | **Não** — submit explícito é decisão de produto (`campanha-edit-where-you-see`); ver Não escopo B77 | +| 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 diff --git a/docs/plans/simplificar-ajuste-votos-wizard.md b/docs/plans/simplificar-ajuste-votos-wizard.md index 9728705c..16fe5d28 100644 --- a/docs/plans/simplificar-ajuste-votos-wizard.md +++ b/docs/plans/simplificar-ajuste-votos-wizard.md @@ -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`. diff --git a/docs/roadmap.md b/docs/roadmap.md index ec51bccc..ad95724c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; **B77** simplificar ajuste votos; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B77/B78. +Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; ~~**B77**~~ ✓ simplificar ajuste votos; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B78. 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) @@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ → **B77** (ajuste votos numa tela) /**B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança). +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓/~~**B77**~~ ✓ → **B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança). - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) @@ -93,7 +93,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **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)~~ — **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) · ~~regressão jump+warning → B76~~ ✓ - **B76** ~~Corrigir coerência do wizard de ajuste de votos~~ — **entregue 2026-07-30** (`useEffect` preserva `violationEditedScenario` no jump ao cenário violado; limpa ao editar draft/atalho; unit `wizardExpectedVotesStep.unit.spec.ts`) · Depende de B61 ✓ · [plano](plans/corrigir-coerencia-wizard-ajuste-votos.md) · UX de três passos → **B77** -- **B77** Simplificar wizard ajuste de votos — três cenários na mesma tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm (warning + highlight, sem `?cenario=` jump) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · Paralelizável com B63/B64 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md) +- **B77** ~~Simplificar wizard ajuste de votos~~ — **entregue 2026-07-30** (`WizardExpectedVotesStep` reescrito: três cenários numa tela com `VoteEstimateScenarioInputs` compact + strip + atalhos no foco; validação só no confirm com `Alert` + highlight; POST batch inalterado; `?cenario=` legado redireciona para URL canônica; órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignActionRoutes`) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · Impeccable B · [plano](plans/simplificar-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) @@ -530,11 +530,11 @@ flowchart TD B8F2 -.rank por ZE = débito aberto.-> B14n ``` -Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); ~~**B75**~~ ✓ (header mobile wizard); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft ~~B75~~ ✓ para shimmer no header; paralelo a B77/B63/B64); **B77** (simplificar ajuste votos — após ~~B61~~ ✓/~~B76~~ ✓; paralelo a B63/B64); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (~~B75~~ ✓). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft ~~B75~~ ✓). Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. +Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); ~~**B75**~~ ✓ (header mobile wizard); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft ~~B75~~ ✓ para shimmer no header; paralelo a B63/B64); ~~**B77**~~ ✓ (simplificar ajuste votos — entregue 2026-07-30); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (~~B75~~ ✓). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft ~~B75~~ ✓). Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → ~~B75~~ ✓** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ → **B77** (ajuste votos 1 tela) ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B77/B78/B63–B64 + A4 ~~B69~~ ✓/B70. +**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → ~~B75~~ ✓** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓/~~B77~~ ✓ ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B78/B63–B64 + A4 ~~B69~~ ✓/B70. **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -544,7 +544,7 @@ Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloq ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); ~~**B76** coerência do ajuste de votos~~ ✓ (entregue 2026-07-30); **B77** simplificar ajuste de votos (cortar = manter ritual pesado de três passos `?cenario=` que o CG pediu simplificar — emenda a B61); ~~**B75** header mobile do wizard~~ ✓ (entregue 2026-07-30); **B73** remover bottom nav (paradigma intenção-primeiro); **B79–B90** bottom drawer de ações rápidas + busca fora do Início + catálogos por vertical (cortar = após B73 o mobile fica sem ação/busca nas páginas de trabalho — decisão de produto 2026-07-30: lote inteiro **não cortável**, execução rápida); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); ~~**B69** simplificar liderança~~ ✓ (entregue 2026-07-30 — `exclusive` default true; setor/`consentNote` dropados; **organização fica**: ramificação política, ex. SindMed); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). +**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); ~~**B76** coerência do ajuste de votos~~ ✓ (entregue 2026-07-30); ~~**B77** simplificar ajuste de votos~~ ✓ (entregue 2026-07-30 — ritual `?cenario=` substituído por editor único); ~~**B75** header mobile do wizard~~ ✓ (entregue 2026-07-30); **B73** remover bottom nav (paradigma intenção-primeiro); **B79–B90** bottom drawer de ações rápidas + busca fora do Início + catálogos por vertical (cortar = após B73 o mobile fica sem ação/busca nas páginas de trabalho — decisão de produto 2026-07-30: lote inteiro **não cortável**, execução rápida); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); ~~**B69** simplificar liderança~~ ✓ (entregue 2026-07-30 — `exclusive` default true; setor/`consentNote` dropados; **organização fica**: ramificação política, ex. SindMed); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). **Cortes seguros** (se o prazo apertar, nesta ordem): ~~**B71**~~ ✓; **B74** gap strip v2 (B72 já apertou uma vez — cortar = strip um pouco esparça); **B78** pending navegação wizard (cortar = Voltar/seleção sem dim/shimmer até rede lenta reclamar — corpo pending pode shippar sem shimmer do B75); **B68** sugestões na busca aberta (digitação B48 continua; cortar = empty mudo no focus até folga); **B64** wizard tendência standalone (A1 ainda pode embutir tendência via B24 ✓ / passo curto; cortar = sem grid colorido + prefill até folga); **B63** wizard sinal standalone (A1 ainda pode embutir select pós-B62; cortar = sem grid/Drawer de tipos até folga); **B70** wizard liderança standalone (A1 ainda pode embutir passo curto pós-B69; cortar = sem grid 2/3/4 até folga — pacote Cairu sofre); **B57** delta 7d (Início continua com total+cobertura do B56; Δ era futuro de propósito); **B56** resumo no Início (Quadro já tem os mesmos agregados — cortar = Início sem briefing numérico); **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); ~~**B17** seletor de colunas~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 46aba292..5e3c6082 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -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 ( @@ -70,9 +67,7 @@ export default async function CampaignActionWizardPage({ actionSlug={slug} municipalityId={municipality.id} municipalityName={municipality.name} - municipalitySlug={municipality.slug} initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)} - currentScenario={scenario} /> ) } 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 56111a76..f227246e 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts +++ b/src/app/(campaign)/campanha/(app)/municipios/expected-votes/types.ts @@ -2,6 +2,9 @@ import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' export const MUNICIPALITY_EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes' as const +export const MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE = + 'Não foi possível salvar os votos estimados. Tente novamente.' as const + export type MunicipalityListExpectedVotesResponse = | { status: 'success' diff --git a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx index 0a60b630..4b409678 100644 --- a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx +++ b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx @@ -2,6 +2,7 @@ import { MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, + MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, type MunicipalityListExpectedVotesResponse, } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' @@ -21,7 +22,6 @@ import { import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews' const AUTOSAVE_MS = 600 -const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.' /** * `municipalityName` is the Drawer's subject line and part of the trigger's @@ -57,7 +57,7 @@ export const MunicipalityListExpectedVotesControl = ({ endpoint: MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, buildBody: (values) => ({ municipalityId: municipalityID, expectedVotes: values }), readSaved: (payload) => payload.savedExpectedVotes, - errorMessage: SAVE_ERROR_MESSAGE, + errorMessage: MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, pendingMessage: 'Salvando votos estimados.', }) diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index b1dbdc05..165da108 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -1,184 +1,108 @@ '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, + MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, 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_FINAL_CTA_LABEL, WIZARD_VOTES_SAVED_MESSAGE, wizardFlowTitleForSlug, - wizardVoteReturnToScenarioLabel, + wizardNextStepPlaceholder, + 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, VOTE_SHORTCUTS, voteShortcutLabels, - wizardVoteFinalCtaLabel, - wizardVoteStepCtaLabel, - wizardVoteStepTitle, type VoteShortcut, - type WizardVoteEditScenario, + type WizardVoteViolation, } 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 [estimates, setEstimates] = useState(initialExpectedVotes) + const [focusedScenario, setFocusedScenario] = useState('central') + const [violation, setViolation] = 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 + const clearViolationState = () => { + if (violation) setViolation(null) + } - 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) - - if (violation) { - setConfirmed(nextConfirmed) - setViolationEditedScenario(currentScenario) + const nextViolation = getWizardVoteViolation(estimates) + if (nextViolation) { + setViolation(nextViolation) 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( MUNICIPALITY_EXPECTED_VOTES_ENDPOINT, { municipalityId, - expectedVotes: nextConfirmed, + expectedVotes: estimates, }, ) if (!ok || payload.status !== 'success') { - setSaveError(payload.status === 'error' ? payload.message : WIZARD_VOTES_SAVE_ERROR_MESSAGE) + setSaveError( + payload.status === 'error' + ? payload.message + : MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE, + ) 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 (

{WIZARD_VOTES_SAVED_MESSAGE}

-

{WIZARD_VOTES_NEXT_FLOW_PLACEHOLDER}

+

{wizardNextStepPlaceholder(actionSlug)}

@@ -203,9 +127,9 @@ export const WizardExpectedVotesStep = ({ return ( @@ -214,35 +138,18 @@ export const WizardExpectedVotesStep = ({ aria-busy={isPending} data-pending={isPending ? '' : undefined} > -
-

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

- - { - 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', - )} - /> -
+
{VOTE_SHORTCUTS.map((shortcut) => ( @@ -259,27 +166,9 @@ export const WizardExpectedVotesStep = ({ ))}
- {showViolationBanner && activeViolation ? ( + {violation ? ( - - {activeViolation.message} - - - - ) : null} - - {draftError ? ( - - {draftError} + {violation.message} ) : null} @@ -305,7 +194,7 @@ export const WizardExpectedVotesStep = ({ Salvando… ) : ( - ctaLabel + WIZARD_VOTES_FINAL_CTA_LABEL )}
diff --git a/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx b/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx index 1061d977..b9d17c2f 100644 --- a/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx +++ b/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx @@ -58,6 +58,10 @@ type VoteEstimateScenarioInputsProps = { idPrefix: string variant?: 'labeled' | 'compact' autoFocusScenario?: VoteEstimateScenario + activeScenario?: VoteEstimateScenario + onFocusScenario?: (scenario: VoteEstimateScenario) => void + errorScenarios?: ReadonlySet + disabled?: boolean onValuesChange?: (values: VoteEstimateScenarioViewModel) => void } @@ -66,6 +70,10 @@ const CompactVoteEstimateScenarioInputs = ({ values, idPrefix, autoFocusScenario, + activeScenario = 'central', + onFocusScenario, + errorScenarios, + disabled = false, onValuesChange, }: Omit & { onValuesChange: (values: VoteEstimateScenarioViewModel) => void @@ -92,6 +100,7 @@ const CompactVoteEstimateScenarioInputs = ({
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} /> ) diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index 0b2bf5e1..a5bf003c 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -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,5 @@ export const parseWizardMunicipioParam = ( 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 } -} +export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean => + parseWizardMunicipioParam(value) !== undefined diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index 4922b562..77680c04 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -31,14 +31,7 @@ export const WIZARD_VOTES_SAVED_MESSAGE = 'Votos estimados atualizados.' as cons 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()}` +export const WIZARD_VOTES_FINAL_CTA_LABEL = 'Salvar estimativas →' as const const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const diff --git a/src/lib/wizardVoteEstimate.ts b/src/lib/wizardVoteEstimate.ts index a8196de1..d0a8e12c 100644 --- a/src/lib/wizardVoteEstimate.ts +++ b/src/lib/wizardVoteEstimate.ts @@ -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 = { '-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') { @@ -50,50 +38,10 @@ export const applyVoteShortcut = (value: number | null, shortcut: VoteShortcut): 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 + highlightScenarios: VoteEstimateScenario[] } export const getWizardVoteViolation = ( @@ -105,9 +53,37 @@ export const getWizardVoteViolation = ( return { violatingScenario, message: buildWizardVoteViolationMessage(estimates, violatingScenario), + highlightScenarios: resolveWizardVoteHighlights(estimates, violatingScenario), } } +const resolveWizardVoteHighlights = ( + estimates: VoteEstimateScenarioViewModel, + violatingScenario: VoteEstimateScenario, +): VoteEstimateScenario[] => { + 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 +110,3 @@ const buildWizardVoteViolationMessage = ( 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 a6d45866..98df7eac 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -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, + }) }) }) diff --git a/tests/unit/campaignActionRoutes.unit.spec.ts b/tests/unit/campaignActionRoutes.unit.spec.ts index 8aa04294..79f56f4d 100644 --- a/tests/unit/campaignActionRoutes.unit.spec.ts +++ b/tests/unit/campaignActionRoutes.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index 14abbe3e..7d1a1a9c 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -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 }) => ( @@ -26,7 +21,6 @@ const defaultProps = { actionSlug: 'atualizar-votos', municipalityId: 1, municipalityName: 'Cairu', - municipalitySlug: 'cairu', initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 }, } @@ -40,43 +34,70 @@ const renderVotesStep = (props: React.ComponentProps { 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( - - - , - ) + 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( - - - , - ) + 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) + }) }) }) diff --git a/tests/unit/wizardVoteEstimate.unit.spec.ts b/tests/unit/wizardVoteEstimate.unit.spec.ts index ebef84d7..885365b0 100644 --- a/tests/unit/wizardVoteEstimate.unit.spec.ts +++ b/tests/unit/wizardVoteEstimate.unit.spec.ts @@ -1,16 +1,7 @@ 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' +import { applyVoteShortcut, getWizardVoteViolation } from '@/lib/wizardVoteEstimate' describe('applyVoteShortcut', () => { it('doubles the current value', () => { @@ -35,54 +26,23 @@ 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() - 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', () => { + it('flags pessimista above média with readable message and highlights', () => { 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') + expect(violation?.highlightScenarios).toEqual(['pessimistic', 'central']) }) - it('flags otimista below média', () => { + it('flags otimista below média with highlights', () => { 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 }) + expect(violation?.highlightScenarios).toEqual(['optimistic', 'central']) }) })