diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 8268dfb1..089e769c 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,7 +7,8 @@ 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/B78 sobre chrome único. +- **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md). +- **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. - **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 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). diff --git a/AGENTS.md b/AGENTS.md index 4e9392a4..272f2b4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **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):** **B70** Wizard atualizar liderança — `/campanha/acoes/atualizar-lideranca` substitui o stub B60 por `WizardLeadershipStep` (grid 2/3/4 + tile Adicionar + info Drawer + Tooltip de `notes` no hover fine) e `WizardLeadershipForm` (6 campos incl. `exclusive` B69); `loadWizardLeadershipTiles` no RSC; writes `updateLeadershipWizard`/`createLeadershipWizard` em transação Contact+Leadership (não `updateLeadershipInternal`); `WIZARD_ENTRY_ACTION_QUERY_KEY` + skip via `trailingAction` quando `entryAction !== 'update-leadership'`; Continuar após Salvar dirty → Início + toast; `truncateNameAtWordBoundary` em `lib/`. Sem migration. Plano: [`docs/plans/wizard-atualizar-lideranca.md`](docs/plans/wizard-atualizar-lideranca.md). Desbloqueia wiring A1 embutido. **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/wizard-atualizar-lideranca.md b/docs/plans/wizard-atualizar-lideranca.md index 1c96349c..d4d51193 100644 --- a/docs/plans/wizard-atualizar-lideranca.md +++ b/docs/plans/wizard-atualizar-lideranca.md @@ -1,12 +1,19 @@ # Wizard atualizar liderança — grid + formulário curto -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-30) +Atualizado em: 2026-07-30 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B70 — UX-1 wizards / A4) Impeccable: C — UI nova (grid de lideranças + formulário de edição/criação no chassis B59) Appetite: ~1,25–1,5 dia eng; 2 “modos” no B59 (grid → form); writes via actions `leadership` existentes; focus + skip + Continuar Responsável: — +## Revisão as-built (2026-07-30) + +- **Loader:** `loadWizardLeadershipTiles` em `utilities/leadership/leadershipData.ts` (view model `WizardLeadershipTileViewModel` em `lib/wizardLeadershipContract.ts`) — não `listMunicipalityLeaderships` das actions. +- **Escrita:** `updateLeadershipWizard` / `createLeadershipWizard` (+ form actions em `acoes/wizardLeadershipFormActions.ts`) com transação Contact + Leadership; `updateLeadershipInternal` **não** cobre nome/celular/e-mail. +- **`entryAction`:** `WIZARD_ENTRY_ACTION_QUERY_KEY` + `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; skip oculto quando entry implícito (`update-leadership` standalone). +- **Continuar:** após Salvar com dirty → `router.push(CAMPAIGN_HOME)` + toast (opção A do plano). + ## Design (Impeccable) Âncoras: `PRODUCT.md` / `DESIGN.md` · `Drawer`/`Tooltip` shadcn · tema `campaign` · sibling visual **B63**/**B64** (tiles) · focus policy do Field Desk. @@ -55,11 +62,11 @@ Município sticky (**B59**) após escolha. ## Objetivos - Passos no shell B59: `WizardLeadershipGridStep` + `WizardLeadershipFormStep` (ou um client island com modo `grid | form`). -- Loader: lideranças do município no escopo do ator (`loadMunicipalityLeaderships` / select enxuto: id, contact name/phone/email, supportStatus, notes, exclusive). +- Loader: lideranças do município no escopo do ator (`loadWizardLeadershipTiles`; select enxuto: id, contact name/phone/email, supportStatus, notes, exclusive). - Info/hover: `notes` truncado com scroll no Drawer se longo; empty = “Sem observação.” - Truncamento de nome: helper puro em `lib/` (word-boundary ellipsis) + unit. - Skip: trailing slot B59; **hidden** se `entryAction === 'update-leadership'`. -- Salvar edit: `updateLeadershipInternal` / action existente (contact name/phone/email + supportStatus + notes + exclusive); create: `createLeadership` mínimo + município atual. +- Salvar edit: `updateLeadershipWizard` (contact + leadership em transação); create: `createLeadershipWizard` mínimo + município atual. - Dirty → `Continuar` no grid; Continuar **não** grava de novo — só avança (writes já no Salvar do form). - Pending nos botões Salvar/Continuar (Feel the action). - Sem migration (B69 já migrou). Sem Consent novo. @@ -122,7 +129,18 @@ Componentes: - **Busca/filtro no grid.** Revisitar se municipio típico do CG tiver >12 lideranças no loader. - **Ramo “anotar votos declarados”.** Revisitar quando A1 pedir pledge no mesmo ritual (hoje fora do form curto). -- **Extrair `WizardChoiceTile`.** Revisitar no 3º grid irmão (já há B63+B64; este é o 3º — **pode** extrair neste item se o diff medir ≥~80 linhas duplicadas; senão defer explícito no `/simplify`). +- **Extrair `WizardChoiceTile`.** Revisitar no 3º grid irmão (já há B63+B64; este é o 3º — deferido no `/simplify`: padrão visual copiado, sem ≥80 linhas duplicadas mensuráveis). +- **`LeadershipCoreFields` compartilhado com `LeadershipForm`.** Revisitar no 3º form com os mesmos 6 campos do wizard. +- **Helper compartilhado de update Contact+Leadership.** Revisitar no 3º write path wizard (hoje create + update cobertos). +- **`parseExclusive` em `formData.ts`.** Revisitar no 3º call site (hoje wizard + `liderancas/nova`). +- **`router.refresh` só no Continuar.** Polish de UX; revisitar se a mesa reclamar de grid stale antes de Continuar. + +## Já resolvido no simplify (não reabrir) + +- `loadWizardLeadershipTiles` mapeia docs direto (sem `toLeadershipRows` / org-deputy). +- Ordenação por nome no servidor; `contactID` removido do tile VM. +- Botão info como sibling (sem nested interactive). +- `isSupportStatus` nos form actions; catch duplicado removido de `createLeadershipWizard`. ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index ad95724c..0f71fe80 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/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; ~~**B70**~~ ✓ wizard A4 liderança; **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/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**~~ ✓ → **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) @@ -98,7 +98,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **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) - ~~**B69** Simplificar modelo de liderança (`exclusive` default true; dropar setor/`consentNote`; **org fica** — ramificação política, decisão 2026-07-30)~~ ✓ (entregue 2026-07-30) · Depende de — · Janela 1 · [plano](plans/simplificar-modelo-lideranca.md) -- **B70** Wizard atualizar liderança (grid 2/3/4 + form curto + skip + Continuar dirty + focus) · Depende de B59 + B69 · soft B60 · ~1,25–1,5d · Janela 1 · Impeccable C · [plano](plans/wizard-atualizar-lideranca.md) +- ~~**B70** Wizard atualizar liderança (grid 2/3/4 + form curto + skip + Continuar dirty + focus)~~ — **entregue 2026-07-30** (`WizardLeadershipStep` + `WizardLeadershipForm`; `loadWizardLeadershipTiles`; `updateLeadershipWizard`/`createLeadershipWizard` com Contact+Leadership; `entryAction` em `campaignActionRoutes`; e2e smoke) · Depende de B59 + B69 ✓ · soft B60 ✓ · ~1,25–1,5d · Janela 1 · Impeccable C · [plano](plans/wizard-atualizar-lideranca.md) - ~~**B46** Posição dos botões de ação (thumb zone)~~ — **entregue 2026-07-29** (`CampaignHomeLayout`: coluna `min-h-full` + spacer `flex-1` só em mobile; `data-slot="home-actions"` / `home-search`; ordem flex `order-*` — mobile ações→busca, `md+` busca→ações); `/campanha` usa `CampaignPageShell` `min-h-full` + layout; decisão sticky vs coluna = coluna (opção B). Unit `campaignHomeLayout.unit.spec.ts`; e2e B45 intacto. Sem migration. Âncora inferior no mobile corrigida por **B65** ✓ (2026-07-29). · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md) - ~~**B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto)~~ — **entregue 2026-07-29** (`CampaignHomeStaffChrome` + `CampaignHomeSearch` + `useHomeSearchQuery`; debounce 250 ms; query ativa ≥2 chars; `HomeSearchContext` para B48+; staff-only via `isStaffCampaignRole`; Escape limpa; região `aria-live` sem hits ainda; sem migration) · [plano](plans/busca-global-inicio-input.md) · **emenda B66:** limpeza no **focus** (não só na digitação) + animação - **B66** ~~Modo focado da busca no **focus** (não na digitação) + animação de retração do chrome (strip/spacer/resumo) como se a barra expandisse — emenda a decisão B47 “expand = query”; hits continuam gated por limiar ≥2~~ — **entregue 2026-07-30** (`homeSearchUiFocused`; `CampaignHomeLayout` grid `0fr/1fr` + opacity 220ms + `motion-reduce`; `HomeSearchHitRow` blur→click; e2e focus sem digitar) · Depende de B47 ✓ · soft ~~B65~~ ✓ · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/modo-focado-busca-no-focus.md) · empty útil → **B68** ✓ @@ -280,7 +280,7 @@ flowchart TD B63n["B63 Wizard sinal
tipo + texto"] B64n["B64 Wizard tendência
escolha + justificativa"] B69n["B69 ✓ Liderança exclusiva
sem setor/consentNote"] - B70n["B70 Wizard atualizar
liderança grid+form"] + B70n["B70 ✓ Wizard atualizar
liderança grid+form"] B15n["B15 ✓ Ordenar lista
por coluna"] B16n["B16 ✓ Filtros no header
da lista"] B17n["B17 ✓ Seletor de colunas
das 7 listas"] diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 5e3c6082..4e323164 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -1,5 +1,6 @@ import { notFound, redirect } from 'next/navigation' +import { WizardLeadershipStep } from '@/components/campaign/leadership/WizardLeadershipStep' import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep' import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep' import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub' @@ -7,7 +8,9 @@ import { CAMPAIGN_WIZARD_ACTION_SLUGS, hasWizardScenarioParam, isCampaignWizardActionSlug, + parseWizardEntryActionParam, parseWizardMunicipioParam, + WIZARD_ENTRY_ACTION_QUERY_KEY, WIZARD_MUNICIPIO_QUERY_KEY, WIZARD_SCENARIO_QUERY_KEY, wizardActionHref, @@ -16,6 +19,7 @@ import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import config from '@/payload.config' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadWizardLeadershipTiles } from '@/utilities/leadership/leadershipData' import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' import { getPayload } from 'payload' @@ -72,5 +76,23 @@ export default async function CampaignActionWizardPage({ ) } + if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-leadership']) { + const entryAction = parseWizardEntryActionParam( + resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY], + ) + const tiles = await loadWizardLeadershipTiles(payload, user, municipality.id) + + return ( + + ) + } + return } diff --git a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts new file mode 100644 index 00000000..55d4682d --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts @@ -0,0 +1,89 @@ +'use server' + +import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages' +import { + createLeadershipWizard, + updateLeadershipWizard, +} from '@/app/(campaign)/campanha/actions/leadership' +import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData' +import { + LEADERSHIP_DUPLICATE_MESSAGE, + LEADERSHIP_INVALID_CONTACT_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + isSupportStatus, +} from '@/lib/schemas/leadership' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' +import { + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + CONTACT_PHONE_CONFLICT_MESSAGE, +} from '@/utilities/contactPhoneInvariant' + +const safeMessages = [ + LEADERSHIP_DUPLICATE_MESSAGE, + LEADERSHIP_INVALID_CONTACT_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + CONTACT_PHONE_AMBIGUOUS_MESSAGE, + CONTACT_PHONE_CONFLICT_MESSAGE, + ...leadershipStaffEditSafeMessages, +] as const + +const parseExclusive = (formData: FormData): boolean | undefined => { + const exclusiveValues = formData.getAll('exclusive') + if (exclusiveValues.length === 0) return undefined + return exclusiveValues.at(-1) === 'true' +} + +const parseSupportStatus = (formData: FormData) => { + const supportStatus = optionalFormText(formData, 'supportStatus') + return isSupportStatus(supportStatus) ? supportStatus : 'a_abordar' +} + +const wizardFieldsFromForm = (formData: FormData) => ({ + name: requiredFormText(formData, 'name'), + phone: requiredFormText(formData, 'phone'), + email: optionalFormText(formData, 'email'), + exclusive: parseExclusive(formData), + supportStatus: parseSupportStatus(formData), + notes: optionalFormText(formData, 'notes'), +}) + +export const createLeadershipWizardFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await createLeadershipWizard( + { + ...wizardFieldsFromForm(formData), + municipalityId: requiredRelationshipFormValue(formData, 'municipalityId'), + }, + requiredFormText(formData, 'municipalitySlug'), + ) + return { message: 'Liderança cadastrada.' } + }, + safeMessages, + genericMessage: 'Não foi possível cadastrar a liderança. Verifique os dados e tente novamente.', + }) + +export const updateLeadershipWizardFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await updateLeadershipWizard( + { + ...wizardFieldsFromForm(formData), + id: requiredRelationshipFormValue(formData, 'leadershipId'), + }, + requiredFormText(formData, 'municipalitySlug'), + ) + return { message: 'Liderança atualizada.' } + }, + safeMessages, + genericMessage: 'Não foi possível salvar a liderança. Verifique os dados e tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index e8d1a2d3..081cc521 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -5,24 +5,30 @@ import type { Payload } from 'payload' import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership' import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership' -import { uniqueRelationshipIds } from '@/lib/relationship' +import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship' import { LEADERSHIP_DUPLICATE_MESSAGE, + LEADERSHIP_INVALID_CONTACT_MESSAGE, LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, LEADERSHIP_STAFF_MESSAGE, leadershipCreateSchema, leadershipInternalUpdateSchema, leadershipMunicipalitiesMembershipSchema, leadershipStateDeputyMembershipSchema, + leadershipWizardCreateSchema, + leadershipWizardUpdateSchema, type LeadershipInternalUpdateInput, type LeadershipMunicipalitiesMembershipInput, type LeadershipStateDeputyMembershipInput, + type LeadershipWizardCreateInput, + type LeadershipWizardUpdateInput, } from '@/lib/schemas/leadership' import type { CampaignUser, Contact } from '@/payload-types' import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' import { acquireContactPhoneLocks, + assertContactPhoneAvailable, CONTACT_PHONE_AMBIGUOUS_MESSAGE, } from '@/utilities/contactPhoneInvariant' import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' @@ -231,6 +237,137 @@ export const updateLeadershipInternal = async (input: LeadershipInternalUpdateIn return updateLeadershipInternalRecord(payload, actor, input) } +const revalidateLeadershipWizardPaths = (leadershipId: number, municipalitySlug: string) => { + revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page') + revalidatePath(`/campanha/municipios/${municipalitySlug}`, 'page') + revalidatePath('/campanha/liderancas', 'page') +} + +const updateLeadershipWizardRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipWizardUpdateInput, +) => { + const data = leadershipWizardUpdateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await getFreshStaffActor(payload, actor, req) + + const current = await payload.findByID({ + collection: 'leadership', + id: data.id, + depth: 1, + user: currentActor, + overrideAccess: false, + req, + }) + + const municipalityIDs = uniqueRelationshipIds(current.municipalities) + await assertMunicipalitiesWithinScope(payload, currentActor, municipalityIDs, req) + + if (payload.db.name !== 'postgres') { + throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE) + } + + const contactID = relationshipId(current.contact) + if (contactID === null) { + throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE) + } + + await acquireContactPhoneLocks(payload, req, [data.phone]) + + // Intentional admin bypass: staff scope was checked on the leadership; contact + // PII is updated atomically with the leadership fields in this transaction. + const contactsWithPhone = await payload.find({ + collection: 'contact', + where: { phone: { equals: data.phone } }, + depth: 0, + limit: 2, + pagination: false, + overrideAccess: true, + req, + }) + + if (contactsWithPhone.totalDocs > 1) { + throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE) + } + + const phoneOwner = contactsWithPhone.docs[0] + if (phoneOwner && phoneOwner.id !== contactID) { + await assertContactPhoneAvailable(payload, req, data.phone, contactID) + } + + // bypass: contact write is staff-scoped via leadership access check above. + await payload.update({ + collection: 'contact', + id: contactID, + data: { + name: data.name, + phone: data.phone, + email: data.email, + }, + depth: 0, + overrideAccess: true, + req, + }) + + return payload.update({ + collection: 'leadership', + id: current.id, + data: { + exclusive: data.exclusive, + supportStatus: data.supportStatus, + notes: data.notes, + }, + depth: 0, + user: currentActor, + overrideAccess: false, + req, + }) + }, + { beginFailureMessage: 'Não foi possível atualizar a liderança.' }, + ) +} + +const createLeadershipWizardRecord = async ( + payload: Payload, + actor: CampaignUser, + input: LeadershipWizardCreateInput, +) => { + const data = leadershipWizardCreateSchema.parse(input) + return createValidatedLeadershipRecord(payload, actor, { + name: data.name, + phone: data.phone, + email: data.email, + municipalities: [data.municipalityId], + exclusive: data.exclusive, + supportStatus: data.supportStatus, + notes: data.notes, + }) +} + +export const updateLeadershipWizard = async ( + input: LeadershipWizardUpdateInput, + municipalitySlug: string, +) => { + const { payload, actor } = await getCampaignActionContext() + const leadership = await updateLeadershipWizardRecord(payload, actor, input) + revalidateLeadershipWizardPaths(leadership.id, municipalitySlug) + return leadership +} + +export const createLeadershipWizard = async ( + input: LeadershipWizardCreateInput, + municipalitySlug: string, +) => { + const { payload, actor } = await getCampaignActionContext() + const leadership = await createLeadershipWizardRecord(payload, actor, input) + revalidateLeadershipWizardPaths(leadership.id, municipalitySlug) + return leadership +} + /** * `/campanha/liderancas` — the list the chip was toggled ON — is deliberately * NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason. diff --git a/src/components/campaign/leadership/WizardLeadershipForm.tsx b/src/components/campaign/leadership/WizardLeadershipForm.tsx new file mode 100644 index 00000000..99235210 --- /dev/null +++ b/src/components/campaign/leadership/WizardLeadershipForm.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useActionState, useEffect, useRef } from 'react' + +import { + createLeadershipWizardFormAction, + updateLeadershipWizardFormAction, +} from '@/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions' +import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/Checkbox' +import { Field, FieldError, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Spinner } from '@/components/ui/Spinner' +import { Textarea } from '@/components/ui/textarea' +import { leadershipSupportStatuses } from '@/lib/schemas/leadership' +import type { WizardLeadershipTileViewModel } from '@/lib/wizardLeadershipContract' +import { fieldError } from '@/utilities/campaignFormFields' +import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels' + +type WizardLeadershipFormProps = { + municipalityId: number + municipalitySlug: string + leadership: WizardLeadershipTileViewModel | null + onSaved: () => void + onCancel: () => void +} + +export const WizardLeadershipForm = ({ + municipalityId, + municipalitySlug, + leadership, + onSaved, + onCancel, +}: WizardLeadershipFormProps) => { + const isCreate = leadership === null + const formAction = isCreate ? createLeadershipWizardFormAction : updateLeadershipWizardFormAction + const [state, submitAction, isPending] = useActionState(formAction, {}) + const nameRef = useRef(null) + const savedRef = useRef(false) + + useEffect(() => { + nameRef.current?.focus() + }, [leadership?.id]) + + useEffect(() => { + if (state.status === 'success' && !savedRef.current) { + savedRef.current = true + onSaved() + } + if (state.status !== 'success') { + savedRef.current = false + } + }, [onSaved, state.status]) + + return ( +
+ + + {!isCreate ? : null} + + + Nome + + {fieldError(state.fieldErrors, 'name') ? ( + {fieldError(state.fieldErrors, 'name')} + ) : null} + + +
+ + Celular + + {fieldError(state.fieldErrors, 'phone') ? ( + {fieldError(state.fieldErrors, 'phone')} + ) : null} + + + E-mail + + {fieldError(state.fieldErrors, 'email') ? ( + {fieldError(state.fieldErrors, 'email')} + ) : null} + +
+ + + Status de apoio + + {leadershipSupportStatuses.map((status) => ( + + {supportStatusLabels[status]} + + ))} + + + + + Observação +