mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(campanha): ship B70 leadership update wizard (grid + short form).
Replaces the post-municipality stub with a full wizard: tile grid, contact+leadership writes, entryAction skip, and Continuar after save.
This commit is contained in:
parent
378b19b20b
commit
dbd9d0fa3e
19 changed files with 912 additions and 11 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
'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,
|
||||
leadershipSupportStatuses,
|
||||
} 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 leadershipSupportStatuses.includes(
|
||||
supportStatus as (typeof leadershipSupportStatuses)[number],
|
||||
)
|
||||
? (supportStatus as (typeof leadershipSupportStatuses)[number])
|
||||
: '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.',
|
||||
})
|
||||
|
|
@ -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,144 @@ 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()
|
||||
try {
|
||||
const leadership = await createLeadershipWizardRecord(payload, actor, input)
|
||||
revalidateLeadershipWizardPaths(leadership.id, municipalitySlug)
|
||||
return leadership
|
||||
} catch (error) {
|
||||
if (isUniqueLeadershipConflict(error)) {
|
||||
throw new Error(LEADERSHIP_DUPLICATE_MESSAGE)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/campanha/liderancas` — the list the chip was toggled ON — is deliberately
|
||||
* NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason.
|
||||
|
|
|
|||
164
src/components/campaign/leadership/WizardLeadershipForm.tsx
Normal file
164
src/components/campaign/leadership/WizardLeadershipForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
src/components/campaign/leadership/WizardLeadershipStep.tsx
Normal file
255
src/components/campaign/leadership/WizardLeadershipStep.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
'use client'
|
||||
|
||||
import { InfoIcon, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, 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 skip = 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 = showSkip ? (
|
||||
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
|
||||
<Link href={CAMPAIGN_HOME}>{WIZARD_LEADERSHIP_SKIP_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)
|
||||
})
|
||||
}
|
||||
|
||||
const sortedTiles = useMemo(
|
||||
() => [...initialTiles].sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')),
|
||||
[initialTiles],
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
stepTitle={stepTitle}
|
||||
isEntryStep={false}
|
||||
previousHref={wizardActionHref(actionSlug)}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
skip={skip}
|
||||
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">
|
||||
{sortedTiles.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">
|
||||
{sortedTiles.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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex aspect-square w-full flex-col rounded-xl border border-border bg-transparent p-3 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="flex items-start justify-between gap-1">
|
||||
<div className="max-w-[calc(100%-2rem)]">
|
||||
{tile.supportStatus ? <SupportStatusBadge status={tile.supportStatus} /> : null}
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex size-8 shrink-0 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={(event) => {
|
||||
event.stopPropagation()
|
||||
onInfo()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onInfo()
|
||||
}
|
||||
}}
|
||||
aria-label={`Ver observação de ${tile.name}`}
|
||||
>
|
||||
<InfoIcon className="size-4" aria-hidden />
|
||||
</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
src/lib/leadershipNameTruncate.ts
Normal file
19
src/lib/leadershipNameTruncate.ts
Normal 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}…`
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
19
src/lib/wizardLeadershipContract.ts
Normal file
19
src/lib/wizardLeadershipContract.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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
|
||||
contactID: 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'
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -125,6 +126,39 @@ export const loadMunicipalityLeaderships = async (
|
|||
return toLeadershipRows(payload, result.docs as Leadership[])
|
||||
}
|
||||
|
||||
export const loadWizardLeadershipTiles = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
): Promise<WizardLeadershipTileViewModel[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const rows = await toLeadershipRows(payload, result.docs as Leadership[])
|
||||
const notesById = new Map(
|
||||
(result.docs as Leadership[]).map((doc) => [doc.id, doc.notes ?? null] as const),
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactID: row.contactID,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
supportStatus: row.supportStatus,
|
||||
exclusive: row.exclusive,
|
||||
notes: notesById.get(row.id) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier variant: only the `limit` most recently updated rows (sorted in
|
||||
* Postgres), plus the full count — avoids fetching every leadership of a
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
17
tests/unit/leadershipNameTruncate.unit.spec.ts
Normal file
17
tests/unit/leadershipNameTruncate.unit.spec.ts
Normal 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…')
|
||||
})
|
||||
})
|
||||
17
tests/unit/wizardLeadershipStep.unit.spec.ts
Normal file
17
tests/unit/wizardLeadershipStep.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue