Merge branch 'cursor/df491142'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 03:52:32 -03:00
commit fbce90069c
19 changed files with 908 additions and 23 deletions

View file

@ -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 `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B78 sobre chrome único.
- **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 `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.
- **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).

View file

@ -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**.

View file

@ -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

View file

@ -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<br/>tipo + texto"]
B64n["B64 Wizard tendência<br/>escolha + justificativa"]
B69n["B69 ✓ Liderança exclusiva<br/>sem setor/consentNote"]
B70n["B70 Wizard atualizar<br/>liderança grid+form"]
B70n["B70 ✓ Wizard atualizar<br/>liderança grid+form"]
B15n["B15 ✓ Ordenar lista<br/>por coluna"]
B16n["B16 ✓ Filtros no header<br/>da lista"]
B17n["B17 ✓ Seletor de colunas<br/>das 7 listas"]

View file

@ -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 (
<WizardLeadershipStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
initialTiles={tiles}
/>
)
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
}

View file

@ -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<CampaignFormActionState> =>
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<CampaignFormActionState> =>
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.',
})

View file

@ -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.

View file

@ -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<HTMLInputElement>(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 (
<form action={submitAction} className="flex flex-col gap-4" aria-busy={isPending}>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
{!isCreate ? <input type="hidden" name="leadershipId" value={leadership.id} /> : null}
<Field>
<FieldLabel htmlFor="wizard-leadership-name">Nome</FieldLabel>
<Input
ref={nameRef}
id="wizard-leadership-name"
name="name"
required
minLength={2}
maxLength={120}
defaultValue={leadership?.name ?? ''}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'name') ? (
<FieldError>{fieldError(state.fieldErrors, 'name')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="wizard-leadership-phone">Celular</FieldLabel>
<Input
id="wizard-leadership-phone"
name="phone"
type="tel"
required
autoComplete="tel-national"
defaultValue={leadership?.phone ?? ''}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'phone') ? (
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="wizard-leadership-email">E-mail</FieldLabel>
<Input
id="wizard-leadership-email"
name="email"
type="email"
defaultValue={leadership?.email ?? ''}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'email') ? (
<FieldError>{fieldError(state.fieldErrors, 'email')}</FieldError>
) : null}
</Field>
</div>
<Field>
<FieldLabel htmlFor="wizard-leadership-status">Status de apoio</FieldLabel>
<NativeSelect
id="wizard-leadership-status"
name="supportStatus"
defaultValue={leadership?.supportStatus ?? 'a_abordar'}
className="min-h-11 w-full"
>
{leadershipSupportStatuses.map((status) => (
<NativeSelectOption key={status} value={status}>
{supportStatusLabels[status]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="wizard-leadership-notes">Observação</FieldLabel>
<Textarea
id="wizard-leadership-notes"
name="notes"
rows={3}
maxLength={3000}
defaultValue={leadership?.notes ?? ''}
/>
</Field>
<Field orientation="horizontal" className="items-center gap-2">
<input type="hidden" name="exclusive" value="false" />
<Checkbox
id="wizard-leadership-exclusive"
name="exclusive"
value="true"
defaultChecked={leadership?.exclusive ?? true}
/>
<FieldLabel htmlFor="wizard-leadership-exclusive" className="font-normal">
Apoio exclusivo
</FieldLabel>
</Field>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" className="min-h-11" onClick={onCancel}>
Voltar
</Button>
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar
</Button>
</div>
</form>
)
}

View file

@ -0,0 +1,239 @@
'use client'
import { InfoIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { WizardLeadershipForm } from '@/components/campaign/leadership/WizardLeadershipForm'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import {
WIZARD_LEADERSHIP_ADD_TILE_LABEL,
WIZARD_LEADERSHIP_CONTINUE_LABEL,
WIZARD_LEADERSHIP_EMPTY_GRID,
WIZARD_LEADERSHIP_EMPTY_NOTES,
WIZARD_LEADERSHIP_FORM_CREATE_TITLE,
WIZARD_LEADERSHIP_FORM_EDIT_TITLE,
WIZARD_LEADERSHIP_GRID_TITLE,
WIZARD_LEADERSHIP_SAVED_TOAST,
WIZARD_LEADERSHIP_SKIP_LABEL,
wizardFlowTitleForSlug,
} from '@/lib/campaignWizardCopy'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
import { cn } from '@/lib/utils'
import {
showLeadershipWizardSkip,
type WizardLeadershipTileViewModel,
} from '@/lib/wizardLeadershipContract'
type WizardLeadershipStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
initialTiles: WizardLeadershipTileViewModel[]
}
type WizardLeadershipMode =
| { kind: 'grid' }
| { kind: 'form'; leadership: WizardLeadershipTileViewModel | null }
const notesLabel = (notes: string | null): string =>
notes?.trim() ? notes.trim() : WIZARD_LEADERSHIP_EMPTY_NOTES
const secondaryContact = (tile: WizardLeadershipTileViewModel): string | null =>
tile.phone?.trim() || tile.email?.trim() || null
export const WizardLeadershipStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
entryAction,
initialTiles,
}: WizardLeadershipStepProps) => {
const router = useRouter()
const [isContinuing, startContinueTransition] = useTransition()
const [mode, setMode] = useState<WizardLeadershipMode>({ kind: 'grid' })
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)
const showSkip = showLeadershipWizardSkip(entryAction)
const skipConfig = showSkip
? { label: WIZARD_LEADERSHIP_SKIP_LABEL, href: CAMPAIGN_HOME }
: undefined
const stepTitle =
mode.kind === 'grid'
? WIZARD_LEADERSHIP_GRID_TITLE
: mode.leadership
? WIZARD_LEADERSHIP_FORM_EDIT_TITLE
: WIZARD_LEADERSHIP_FORM_CREATE_TITLE
const trailingAction = skipConfig ? (
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
<Link href={skipConfig.href}>{skipConfig.label}</Link>
</Button>
) : undefined
const handleSaved = useCallback(() => {
setDirty(true)
setMode({ kind: 'grid' })
router.refresh()
toast.success(WIZARD_LEADERSHIP_SAVED_TOAST)
}, [router])
const handleContinue = () => {
startContinueTransition(() => {
router.push(CAMPAIGN_HOME)
})
}
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={stepTitle}
isEntryStep={false}
previousHref={wizardActionHref(actionSlug)}
dismissHref={CAMPAIGN_HOME}
municipalityLabel={municipalityName}
skip={skipConfig}
trailingAction={trailingAction}
>
{mode.kind === 'form' ? (
<WizardLeadershipForm
municipalityId={municipalityId}
municipalitySlug={municipalitySlug}
leadership={mode.leadership}
onSaved={handleSaved}
onCancel={() => setMode({ kind: 'grid' })}
/>
) : (
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-4">
{initialTiles.length === 0 ? (
<p className="text-sm text-muted-foreground">{WIZARD_LEADERSHIP_EMPTY_GRID}</p>
) : null}
<ul className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{initialTiles.map((tile) => (
<li key={tile.id}>
<LeadershipTileButton
tile={tile}
onOpen={() => setMode({ kind: 'form', leadership: tile })}
onInfo={() => setInfoTile(tile)}
/>
</li>
))}
<li>
<button
type="button"
className="flex aspect-square w-full flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-transparent p-3 text-center text-sm text-muted-foreground transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setMode({ kind: 'form', leadership: null })}
>
<PlusIcon className="size-6" aria-hidden />
{WIZARD_LEADERSHIP_ADD_TILE_LABEL}
</button>
</li>
</ul>
</div>
</TooltipProvider>
)}
{dirty && mode.kind === 'grid' ? (
<div className="fixed bottom-4 right-4 z-20 pb-[max(0px,env(safe-area-inset-bottom))]">
<Button className="min-h-11 shadow-md" onClick={handleContinue} disabled={isContinuing}>
{WIZARD_LEADERSHIP_CONTINUE_LABEL}
</Button>
</div>
) : null}
<Drawer open={infoTile !== null} onOpenChange={(open) => !open && setInfoTile(null)}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{infoTile?.name ?? 'Observação'}</DrawerTitle>
<DrawerDescription className="sr-only">
Observação interna da liderança
</DrawerDescription>
</DrawerHeader>
<div className="max-h-[50vh] overflow-y-auto px-4 pb-6 text-sm whitespace-pre-wrap">
{notesLabel(infoTile?.notes ?? null)}
</div>
</DrawerContent>
</Drawer>
</CampaignWizardShell>
)
}
type LeadershipTileButtonProps = {
tile: WizardLeadershipTileViewModel
onOpen: () => void
onInfo: () => void
}
const LeadershipTileButton = ({ tile, onOpen, onInfo }: LeadershipTileButtonProps) => {
const secondary = secondaryContact(tile)
const noteText = notesLabel(tile.notes)
return (
<div className="relative aspect-square w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-full w-full flex-col rounded-xl border border-border bg-transparent p-3 pr-11 text-left transition-colors',
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'pointer-coarse:hover:bg-transparent',
)}
onClick={onOpen}
>
<div className="max-w-full">
{tile.supportStatus ? <SupportStatusBadge status={tile.supportStatus} /> : null}
</div>
<div className="mt-auto flex flex-col gap-1">
<p className="text-sm font-medium leading-snug text-foreground">
{truncateNameAtWordBoundary(tile.name, 28)}
</p>
{secondary ? (
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
) : null}
{!tile.exclusive ? (
<Badge variant="outline" className="mt-1 w-fit text-xs">
Não exclusivo
</Badge>
) : null}
</div>
</button>
</TooltipTrigger>
<TooltipContent className="pointer-coarse:hidden max-w-xs whitespace-pre-wrap">
{noteText}
</TooltipContent>
</Tooltip>
<button
type="button"
className="absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onInfo}
aria-label={`Ver observação de ${tile.name}`}
>
<InfoIcon className="size-4" aria-hidden />
</button>
</div>
)
}

View file

@ -42,12 +42,25 @@ 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): string => {
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
}
export const wizardActionHref = (
actionSlug: string,
municipalitySlug?: string,
options?: WizardActionHrefOptions,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (options?.entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, options.entryAction)
}
return `${base}?${params.toString()}`
}
@ -61,3 +74,14 @@ export const parseWizardMunicipioParam = (
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean =>
parseWizardMunicipioParam(value) !== undefined
export const parseWizardEntryActionParam = (
value: string | string[] | undefined,
): CampaignWizardActionId | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed || !isCampaignWizardActionId(trimmed)) {
return undefined
}
return trimmed
}

View file

@ -48,3 +48,22 @@ export const wizardNextStepPlaceholder = (actionSlug: string): string => {
}
return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
}
export const WIZARD_LEADERSHIP_GRID_TITLE = 'Quem coordena por aqui?' as const
export const WIZARD_LEADERSHIP_FORM_CREATE_TITLE = 'Nova liderança' as const
export const WIZARD_LEADERSHIP_FORM_EDIT_TITLE = 'Atualizar liderança' as const
export const WIZARD_LEADERSHIP_SKIP_LABEL = 'Pular atualização de liderança →' as const
export const WIZARD_LEADERSHIP_CONTINUE_LABEL = 'Continuar' as const
export const WIZARD_LEADERSHIP_ADD_TILE_LABEL = 'Adicionar liderança' as const
export const WIZARD_LEADERSHIP_EMPTY_NOTES = 'Sem observação.' as const
export const WIZARD_LEADERSHIP_EMPTY_GRID =
'Nenhuma liderança cadastrada neste município ainda.' as const
export const WIZARD_LEADERSHIP_SAVED_TOAST = 'Liderança salva.' as const

View file

@ -0,0 +1,19 @@
/** Word-boundary ellipsis for leadership tile titles (B70). */
export const truncateNameAtWordBoundary = (name: string, maxChars: number): string => {
const trimmed = name.trim()
if (trimmed.length <= maxChars) return trimmed
const words = trimmed.split(/\s+/)
let result = ''
for (const word of words) {
const candidate = result ? `${result} ${word}` : word
if (candidate.length > maxChars) break
result = candidate
}
if (!result) {
return `${trimmed.slice(0, Math.max(1, maxChars - 1))}…`
}
return `${result}…`
}

View file

@ -46,6 +46,8 @@ export const LEADERSHIP_STAFF_MESSAGE =
export const LEADERSHIP_DUPLICATE_MESSAGE =
'Esta pessoa já está cadastrada como liderança. Edite a ficha existente para vincular novos municípios.'
export const LEADERSHIP_INVALID_CONTACT_MESSAGE = 'Contato da liderança inválido.'
const municipalitiesArraySchema = z
.array(positiveRelationshipId)
.min(1, LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE)
@ -87,6 +89,26 @@ export const leadershipInternalUpdateSchema = z.object({
export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpdateSchema>
const leadershipWizardFieldsSchema = z.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
email: optionalPersistedEmail,
exclusive: z.boolean().default(true),
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),
notes: trimmedOptionalText(3000),
})
export const leadershipWizardCreateSchema = leadershipWizardFieldsSchema.extend({
municipalityId: positiveRelationshipId,
})
export const leadershipWizardUpdateSchema = leadershipWizardFieldsSchema.extend({
id: positiveRelationshipId,
})
export type LeadershipWizardCreateInput = z.input<typeof leadershipWizardCreateSchema>
export type LeadershipWizardUpdateInput = z.input<typeof leadershipWizardUpdateSchema>
/** Delta write for one chip in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
export const leadershipStateDeputyMembershipSchema = z.object({
leadershipId: positiveRelationshipId,

View file

@ -0,0 +1,18 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import type { SupportStatus } from '@/lib/schemas/leadership'
/** Client-safe tile payload for the B70 leadership wizard grid. */
export type WizardLeadershipTileViewModel = {
id: number
name: string
phone: string | null
email: string | null
supportStatus: SupportStatus | null
exclusive: boolean
notes: string | null
}
export const showLeadershipWizardSkip = (entryAction?: CampaignWizardActionId): boolean => {
const effectiveEntry = entryAction ?? 'update-leadership'
return effectiveEntry !== 'update-leadership'
}

View file

@ -5,6 +5,7 @@ import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { isSupportStatus } from '@/lib/schemas/leadership'
import type { WizardLeadershipTileViewModel } from '@/lib/wizardLeadershipContract'
import type { CampaignUser, Leadership } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
@ -53,6 +54,38 @@ const contactSummary = (
return { id: Number(contact), name: 'Contato', phone: null, email: null }
}
const findMunicipalityLeadershipDocs = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
sort = 'createdAt',
): Promise<Leadership[]> => {
const result = await payload.find({
collection: 'leadership',
where: { municipalities: { in: [municipalityID] } },
depth: 1,
limit: 0,
pagination: false,
sort,
user,
overrideAccess: false,
})
return result.docs as Leadership[]
}
const toWizardLeadershipTile = (doc: Leadership): WizardLeadershipTileViewModel => {
const contact = contactSummary(doc.contact)
return {
id: doc.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
exclusive: doc.exclusive ?? true,
notes: doc.notes ?? null,
}
}
const organizationNamesForIds = (payload: Payload, ids: number[]): Promise<Map<number, string>> =>
loadOrganizationNamesByIds(payload, ids)
@ -112,17 +145,19 @@ export const loadMunicipalityLeaderships = async (
user: CampaignUser,
municipalityID: number,
): Promise<LeadershipRowViewModel[]> => {
const result = await payload.find({
collection: 'leadership',
where: { municipalities: { in: [municipalityID] } },
depth: 1,
limit: 0,
pagination: false,
sort: 'createdAt',
user,
overrideAccess: false,
})
return toLeadershipRows(payload, result.docs as Leadership[])
const docs = await findMunicipalityLeadershipDocs(payload, user, municipalityID)
return toLeadershipRows(payload, docs)
}
export const loadWizardLeadershipTiles = async (
payload: Payload,
user: CampaignUser,
municipalityID: number,
): Promise<WizardLeadershipTileViewModel[]> => {
const docs = await findMunicipalityLeadershipDocs(payload, user, municipalityID)
return docs
.map(toWizardLeadershipTile)
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
}
/**

View file

@ -247,3 +247,36 @@ test.describe('Wizard — ajuste de votos (B61 / B77)', () => {
})
})
})
test.describe('Wizard — atualizar liderança (B70)', () => {
test('create leadership, save, and continue to home', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
const suffix = Date.now().toString().slice(-8).padStart(8, '0')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-lideranca?municipio=cairu')
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
timeout: 15000,
})
await page.getByRole('button', { name: 'Adicionar liderança' }).click()
await expect(page.getByRole('heading', { name: 'Nova liderança' })).toBeVisible()
await page.getByLabel('Nome').fill(`Liderança B70 ${suffix}`)
await page.getByLabel('Celular').fill(`719${suffix}`)
await page.getByRole('button', { name: 'Salvar' }).click()
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('button', { name: 'Continuar' })).toBeVisible()
await page.getByRole('button', { name: 'Continuar' }).click()
await page.waitForURL('/campanha')
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
})
})

View file

@ -8,6 +8,7 @@ import {
hasWizardScenarioParam,
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardMunicipioParam,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
@ -36,6 +37,9 @@ describe('campaignActionRoutes', () => {
expect(wizardActionHref('atualizar-votos', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
expect(wizardActionHref('atualizar-lideranca', 'cairu', { entryAction: 'update-votes' })).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
)
})
it('detects legacy scenario search param', () => {
@ -55,4 +59,10 @@ describe('campaignActionRoutes', () => {
expect(campaignWizardActionIdForSlug('atualizar-votos')).toBe('update-votes')
expect(campaignWizardActionIdForSlug('inventado')).toBeUndefined()
})
it('parses entry action search param', () => {
expect(parseWizardEntryActionParam('update-leadership')).toBe('update-leadership')
expect(parseWizardEntryActionParam('invalid')).toBeUndefined()
expect(parseWizardEntryActionParam(undefined)).toBeUndefined()
})
})

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { truncateNameAtWordBoundary } from '@/lib/leadershipNameTruncate'
describe('truncateNameAtWordBoundary', () => {
it('returns the full name when it fits', () => {
expect(truncateNameAtWordBoundary('Maria Silva', 20)).toBe('Maria Silva')
})
it('truncates at the last whole word that fits', () => {
expect(truncateNameAtWordBoundary('Fernando da Costa Lima', 12)).toBe('Fernando da…')
})
it('falls back to character slice when the first word exceeds the limit', () => {
expect(truncateNameAtWordBoundary('Supercalifragilistic', 8)).toBe('Superca…')
})
})

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { showLeadershipWizardSkip } from '@/lib/wizardLeadershipContract'
describe('showLeadershipWizardSkip', () => {
it('hides skip for standalone entry (implicit update-leadership)', () => {
expect(showLeadershipWizardSkip(undefined)).toBe(false)
})
it('hides skip when entry is update-leadership', () => {
expect(showLeadershipWizardSkip('update-leadership')).toBe(false)
})
it('shows skip when embedded from another wizard action', () => {
expect(showLeadershipWizardSkip('update-votes')).toBe(true)
})
})