From f9f9a14994b7a276e6c37476d47c1cbd5b85fa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 14:40:59 -0300 Subject: [PATCH] B45: mount persona home action catalog on /campanha. Staff get six launchers with uncovered-municipalities linking to the sem_assessor list; leaders get two with contacts wired to the shared route constant. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 7 +- .../catalogo-acoes-inicio-por-persona.md | 4 +- docs/roadmap.md | 18 +-- src/app/(campaign)/campanha/(app)/page.tsx | 18 ++- .../dashboard/CampaignHomeActionButton.tsx | 4 +- .../dashboard/CampaignHomeActionStrip.tsx | 8 +- .../dashboard/CampaignHomeActions.tsx | 28 ++++ src/components/campaign/shell/nav.ts | 3 +- src/lib/campaignHomeActions.ts | 132 ++++++++++++++++++ src/lib/campaignPaths.ts | 2 + src/utilities/campaignPageActor.ts | 3 +- tests/e2e/campaignHomeActions.e2e.spec.ts | 60 ++++++++ tests/unit/campaignHomeActions.unit.spec.ts | 63 +++++++++ 13 files changed, 327 insertions(+), 23 deletions(-) create mode 100644 src/components/campaign/dashboard/CampaignHomeActions.tsx create mode 100644 src/lib/campaignHomeActions.ts create mode 100644 src/lib/campaignPaths.ts create mode 100644 tests/e2e/campaignHomeActions.e2e.spec.ts create mode 100644 tests/unit/campaignHomeActions.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 8b0a07ab..7ab55a60 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,10 +7,11 @@ alwaysApply: false ## Status -- **B46–B55 registrados no roadmap (2026-07-29, sem código):** thumb-zone do Início (**B46**) + busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 → B46 → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only; clique = rotas de detalhe existentes; TIs no grupo Municípios. -- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). **Sem** montar catálogo em `/campanha` — B45. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). Desbloqueia **B45**. +- **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md). Próximo: **B46** thumb-zone → **B47–B55** busca global. +- **B46–B55 registrados no roadmap (2026-07-29, sem código):** thumb-zone do Início (**B46**) + busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only; clique = rotas de detalhe existentes; TIs no grupo Municípios. +- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). - **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**. -- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → **B45** catálogo → **B46** thumb-zone → **B47–B55** busca global (wizards depois). +- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → **B46** thumb-zone → **B47–B55** busca global (wizards depois). - **E12+ entregue em código (2026-07-29):** escala/DRY pós-E12 — F1 forçada: `src/lib/metropolitanoTerritoryPeers.ts` unifica constantes, filtros Salvador vs demais RMS e `catalogPeersForSlug` (rollup E17/E12, `slugsForMetropolitanoSubRow`, benchmark T4). F2 não entrou — `benchmarkBySlug` já memoiza por slug. Spec `metropolitanoTerritoryPeers.unit.spec.ts`. Sem migration/UI. Plano: [`escala-dry-pos-e12.md`](../../../docs/plans/escala-dry-pos-e12.md). - **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md). - **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md). diff --git a/docs/plans/catalogo-acoes-inicio-por-persona.md b/docs/plans/catalogo-acoes-inicio-por-persona.md index 3ab89aec..6ccab5aa 100644 --- a/docs/plans/catalogo-acoes-inicio-por-persona.md +++ b/docs/plans/catalogo-acoes-inicio-por-persona.md @@ -1,7 +1,7 @@ # Catálogo de ações do Início por persona -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: catálogo em `src/lib/campaignHomeActions.ts`; mount via `CampaignHomeActions` (client island — RSC não passa ícones Lucide); href de municípios sem assessor resolvido no server; e2e `campaignHomeActions.e2e.spec.ts`. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B45 — chassis UX-1) Impeccable: B — mount da strip B44 no Início com catálogo por role Appetite: ~0,5 dia eng; módulo client-safe + page wire-up; 2 atalhos com href; resto inerte diff --git a/docs/roadmap.md b/docs/roadmap.md index 7f239fff..8a93e2bf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — B46–B55 registrados (thumb-zone + busca global fatiada); B44 ✓; B45 pendente. +Atualizado em: 2026-07-29 — B45 ✓ (catálogo ações Início UX-1); B46–B55 registrados (thumb-zone + busca global fatiada). 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,11 +83,11 @@ 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:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → **B45** → **B46** (thumb-zone) → **B47–B55** (busca global fatiada); wizards com escrita = fatias seguintes. +- **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:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → **B46** (thumb-zone) → **B47–B55** (busca global fatiada); wizards com escrita = fatias seguintes. - ~~**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; sem mount no Início — B45) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) -- **B45** Catálogo de ações por persona — CG/candidato e assessor (6 ações; `uncovered-municipalities` já navega); liderança (2; `my-contacts` → `/campanha/contatos`); resto inerte até wizards. Depende de B44 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/catalogo-acoes-inicio-por-persona.md) -- **B46** Posição dos botões de ação (thumb zone) — mobile: strip perto do limite inferior (acima da bottom nav); `md+`: perto do topo; ordem relativa com a busca (mobile ações→busca; desktop busca→ações). Depende de B45 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.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) +- **B46** Posição dos botões de ação (thumb zone) — mobile: strip perto do limite inferior (acima da bottom nav); `md+`: perto do topo; ordem relativa com a busca (mobile ações→busca; desktop busca→ações). Depende de B45 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md) - **B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto) · staff-only · Depende de B46 · ~1–1,5d · Janela 1 · Impeccable C · [plano](plans/busca-global-inicio-input.md) - **B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card) · Depende de B47 · ~1–1,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-municipios.md) - **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md) @@ -199,7 +199,7 @@ flowchart TD B42n["B42 ✓ Polimento mobile
lista municípios"] B43n["B43 ✓ Início em branco
+ Quadro / Contatos"] B44n["B44 ✓ Botão ação
+ strip horizontal"] - B45n["B45 Catálogo ações
por persona"] + B45n["B45 ✓ Catálogo ações
por persona"] B46n["B46 Thumb zone
ações Início"] B47n["B47 Busca global
input + modo focado"] B48n["B48 Busca
Municípios + TIs"] @@ -377,11 +377,11 @@ flowchart TD B8F2 -.rank por ZE = débito aberto.-> B14n ``` -Paralelizáveis agora: **B45** (próximo do chassis UX-1 — desbloqueia B46–B55); após **B47**, **B49–B53** em paralelo entre si (soft ordem visual sob B48). Demais já listados: ~~**B43**~~ ✓ (chassis UX-1 — entregue 2026-07-29), ~~**B42**~~ ✓ (polimento mobile da mesma lista — entregue 2026-07-27), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), ~~**B17**~~ ✓ (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), ~~**B18**~~ ✓ (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; remover lista de zonas do Início — pós-B8 F2 ✓; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), ~~**B32**~~ ✓ (editar Status de apoio na célula da lista de lideranças; entregue 2026-07-26), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). +Paralelizáveis agora: **B46** (próximo do chassis UX-1 — desbloqueia B47–B55); após **B47**, **B49–B53** em paralelo entre si (soft ordem visual sob B48). Demais já listados: ~~**B43**~~ ✓ (chassis UX-1 — entregue 2026-07-29), ~~**B42**~~ ✓ (polimento mobile da mesma lista — entregue 2026-07-27), **B21** (página dos TIs; sem dep dura — E17 ✓/B15 ✓/B16 ✓ já entregues; E12 pousa nela depois; **B25** é o único item que espera B21 — o link do território na lista só existe depois do destino), ~~**B17**~~ ✓ (seletor de colunas; soft B15 ✓/B16 ✓ — pousa no header de coluna atual), ~~**B18**~~ ✓ (filtros salvos + submenu Municípios; soft B16 ✓/B17 — botão na barra), **B22** (explicação por coluna no header; sem dep dura — B15 ✓/B16 ✓/E18 ✓), **B23** (tooltip nas células; a mecânica genérica e a promoção do primitivo já saíram no E10 ✓ — restam os consumidores Assessores/Tendência e a convivência com o Popover), ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência; entregue 2026-07-26), **B26** (registrar sinal na célula "Último sinal"; sem dep dura — E9 ✓/C12 ✓ já entregues; soft B24/B23 só pela coerência de contrato na mesma linha), **B27** (combobox + chips + auto-save no popover de Assessores; sem dep dura — B19 ✓ já deu o delta+lock e o padrão de chips; soft B24 pelo endpoint e B23 pelo gatilho), **B28** (e-mail + celular na lista de lideranças; sem dep dura — B19 ✓ é só o padrão soft de copy/WhatsApp; `phone` já está no view model), **B29** (sort + filtro no header da lista de lideranças; sem dep dura — B15 ✓/B16 ✓ deram o contrato e o popover; soft B21 e B28 só pela ordem de chegada: quem entrar depois herda o head compartilhado / encaixa as colunas do outro), fill-ins (renomear coluna Cobertura → quick win de densidade no header; remover coluna Tipo — quick win de densidade; remover lista de zonas do Início — pós-B8 F2 ✓; ícone de prioridade na lista; Cenário junto aos filtros — encaixa na barra slim de B16 ✓ —; selecionados no topo nos filtros do header — helper puro que B21 reusa —; O0+, RS+). **B30** (convite WhatsApp na lista de lideranças; sem dep dura — reusa integralmente a action/collection existentes; soft B28 só pelo ícone na mesma linha, qualquer um pode ir primeiro — **entregue 2026-07-26**), **B31** (coluna de dobradinhas na lista de lideranças; sem dep dura — o dado já está no view model e o catálogo já tem loader; soft B27 pela peça de célula, que só vira extração compartilhada no 3º call site, e B28/B29/B30 pela ordem de chegada na mesma tabela), ~~**B32**~~ ✓ (editar Status de apoio na célula da lista de lideranças; entregue 2026-07-26), **B33** (sort + filtro no header da lista de dobradinhas; sem dep dura — B15 ✓/B16 ✓ deram o contrato; soft B29/B21 só pela ordem de chegada na mesma corrida de extração do chrome compartilhado, quem entrar primeiro paga a peça e o outro só consome), **B34** (chips editáveis de municípios na lista de lideranças; sem dep dura — B19 ✓ já deu o delta+lock, o padrão de chips e o loader do catálogo a promover; soft B27/B31 pela mesma peça de célula editável — quem chegar depois herda o combobox/Popover em vez de reinventar —, e B28/B30/B32 só pela mesma tabela; _decisão a validar com produto_: Popover por célula em vez do toggle "Editar" pedido literalmente, mesmo precedente do B31/B32), **B37** (chips editáveis de municípios na lista de dobradinhas; sem dep dura — B19 ✓/B34 já dão a lib a promover e o desenho do Popover+combobox; soft B34 só pela ordem de chegada em quem faz a promoção da lib, B31 pela mesma família de pedido em outra tabela, B33 pela mesma tabela; _mesma decisão a validar com produto_ do B31/B32/B34: Popover por célula, não toggle). **B36** (coluna de lideranças na lista de dobradinhas; sem dep dura — reusa integralmente a mutação/schema/lock que o B31 desenha para a mesma relação, do lado oposto; soft B31 pela corrida de extração da célula/action compartilhadas, B19 ✓ pelo padrão de origem, B33 pela mesma tabela), **C13** (renomear Plano de Ação → Atividade; sem dep dura — não toca nenhuma das listas em curso (B26–B37 são municípios/lideranças/dobradinhas), então roda em paralelo a elas; a única ordem que importa é **antes de E13**, que constrói dentro da vertical renomeada, e antes do congelamento ~20/09 por carregar migration; PR mecânico e isolado, não misturar com feature). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9). ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43~~ ✓ → ~~B44~~ ✓ → B45 → B46 → B47 → B48** (e em paralelo B49–B53; depois B54/B55) — chassis UX-1 + busca global; soft 03/08 · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; ~~**B38**~~ ✓ sidebar recolhido em tablet (entregue 2026-07-26 — `offcanvas` + trigger `md+` + cookie/`matchMedia`); **B39** (lembrar de mim / sessão longa — o default de 2 h está derrubando sessão no onboarding; ~0,5 dia, entra cedo) e, logo atrás se folga, ~~**B40**~~ ✓ (biometria WebAuthn — entregue 2026-07-28); ~~**B41**~~ ✓ (scroll horizontal + sticky na lista de municípios — entregue 2026-07-27) e ~~**B42**~~ ✓ (polimento mobile da mesma lista — Drawer em Tendência/Assessores/Votos estimados + card compacto e clicável; entregue 2026-07-27); **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); ~~**B32**~~ ✓ (editar Status de apoio na célula da mesma lista — entregue 2026-07-26; fecha o adiado do próprio B29); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); ~~**B17**~~ ✓ (seletor de colunas) / ~~**B18**~~ ✓ (filtros salvos + atalho sidebar) entregues 2026-07-28; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14 ✓** (atalho geo entregue 2026-07-26); Onda 0 jurídica em paralelo (externa). +**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → B46 → B47 → B48** (e em paralelo B49–B53; depois B54/B55) — chassis UX-1 + busca global; soft 03/08 · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19 ✓** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras) entregue 2026-07-24; **B16 ✓** filtros no header entregue 2026-07-25; **B20 ✓** Municípios prioritários no Início entregue 2026-07-25; ~~**B38**~~ ✓ sidebar recolhido em tablet (entregue 2026-07-26 — `offcanvas` + trigger `md+` + cookie/`matchMedia`); **B39** (lembrar de mim / sessão longa — o default de 2 h está derrubando sessão no onboarding; ~0,5 dia, entra cedo) e, logo atrás se folga, ~~**B40**~~ ✓ (biometria WebAuthn — entregue 2026-07-28); ~~**B41**~~ ✓ (scroll horizontal + sticky na lista de municípios — entregue 2026-07-27) e ~~**B42**~~ ✓ (polimento mobile da mesma lista — Drawer em Tendência/Assessores/Votos estimados + card compacto e clicável; entregue 2026-07-27); **B21** (página dos Territórios de Identidade — a leitura regional sai do painel do Início e ganha tela própria com sort/filtro; útil já no onboarding para dividir carteiras) se folga — e, logo atrás dela, **B25** (link do território na célula da lista: quarto de dia, é o que faz a página nova ser encontrada de dentro do trabalho); **B22** (explicação por coluna no header — a tabela deixa de exigir o autor do E8/E9 ao lado para ser lida; quick win de onboarding, meio dia); **B23** (tooltip nas células — nomes dos assessores no hover da coluna, hoje só iniciais, e a justificativa da tendência, hoje invisível na lista; capacidade genérica da célula, meio dia, casa com B22 pelo mesmo primitivo); ~~**B24**~~ ✓ (auto-save + justificativa no popover de Tendência — entregue 2026-07-26; a mesa corrige em massa as notas semeadas pelo E4R ✓ sem abrir 435 telas de edição); **B26** (registrar sinal na própria célula "Último sinal" — fecha o laço do E9 ✓: hoje a fila diz onde está frio e obriga a sair dela para registrar qualquer coisa; ~0,75 dia, e é o item que mais aumenta a chance do gate de adoção abaixo, porque o registro passa a caber no gesto de varrer a lista, inclusive no celular); **B27** (combobox + chips + auto-save nos Assessores — é literalmente a tela em que o CG divide as carteiras no onboarding: hoje procura o nome numa coluna de checkboxes e confirma num botão; ~0,75 dia, casa com B24 pelo mesmo endpoint e com B23 pelo mesmo gatilho); **B28** (e-mail + celular na lista de lideranças — copy + WhatsApp como em assessores, sem abrir a ficha; ~0,5–0,75 dia, quick win de campo no onboarding da rede); **B30** (convidar para completar cadastro por WhatsApp na mesma lista — reusa o convite que já existe no detalhe, sem abrir a ficha; ~0,25–0,5 dia, o quick win mais barato desta leva porque não toca action/collection nenhuma; casa com B28 pela mesma linha, ícone distinto); **B31** (coluna de dobradinhas na mesma lista — é o preenchimento em lote que o onboarding exige: o E4R ✓ deixou dobradinha só no município e as fichas vazias, e completar uma a uma pelo detalhe é o gesto que trava a mesa; ~1 dia, casa com B27 pela mesma peça de célula — quem for primeiro define o padrão do chip+combobox in-list); ~~**B32**~~ ✓ (editar Status de apoio na célula da mesma lista — entregue 2026-07-26; fecha o adiado do próprio B29); **B34** (chips editáveis de municípios na mesma lista — a coluna "Municípios" ganha o mesmo tratamento da coluna homônima em Assessores (B19 ✓): chips com link + Popover de edição com combobox, em vez do texto morto atual; ~1–1,25 dia, casa com B27/B31 pela mesma peça de célula editável — quem entrar antes define o padrão do combobox+chip in-list que os outros herdam); **B37** (o mesmo tratamento de chips editáveis na coluna "Municípios" de `/campanha/dobradinhas` — hoje um número morto, `municipalityCount`; ~0,75–1 dia, herda a lib promovida pelo B34 se ele já tiver landado, senão promove ela mesma); **B36** (a coluna "Lideranças" da mesma lista ganha o mesmo tratamento — chips nomeados + Popover + busca + auto-save por delta, reusando sem alteração a mutação que o B31 desenha para o lado oposto da mesma relação; ~0,5–1,5 dia dependendo de quem entre B31/B36 chega primeiro na corrida de extração da célula compartilhada); **B33** (sort + filtro no header de `/campanha/dobradinhas` — pedido explícito de reuso: Nome/Partido no header e filtro por Partido, sem duplicar o chrome de `/campanha/municipios`; ~0,5–1,5 dia dependendo de quem chega primeiro na corrida de extração do chrome compartilhado com B29/B21; entra perto de B29 por reusar a mesma peça); **B29** (sort + filtro no header da mesma lista — é a tela onde o assessor recebe a carteira e precisa recortar "quem ainda está a abordar aqui" / "quem ainda não tem acesso ao app" antes de convidar; ~1,5–2 dias porque paga também a extração do head compartilhado, então entra depois dos quick wins — mas antes se B21 for adiado, para o head nascer compartilhado); ~~**B17**~~ ✓ (seletor de colunas) / ~~**B18**~~ ✓ (filtros salvos + atalho sidebar) entregues 2026-07-28; fill-in **renomear coluna Cobertura** (header curto na fila — quarto de dia; glossário/KPI intactos); fill-in **remover coluna Tipo** (densidade morta na fila — quarto/meio dia; Salvador via slugs cobre ZE); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28); fill-in **selecionados no topo nos filtros do header** (desmarcar sem caçar checkbox; helper puro que B21 herda — quarto de dia); **C13 ✓** (renomeado "Plano de Ação" → "Atividade" em toda a vertical, incluindo a migration data-preserving — entregue 2026-07-25); gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14 ✓** (atalho geo entregue 2026-07-26); Onda 0 jurídica em paralelo (externa). **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. @@ -391,7 +391,7 @@ Paralelizáveis agora: **B45** (próximo do chassis UX-1 — desbloqueia B46–B ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); **B45–B48** chassis UX-1 + busca mínima (catálogo → thumb-zone → input → Municípios — cortar = Início sem ações montadas / sem achar município até 03/08); ~~**B44**~~ ✓; ~~**B43**~~ ✓ (entregue 2026-07-29); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). +**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B45** chassis UX-1~~ ✓ (entregue 2026-07-29); **B46–B48** thumb-zone + busca mínima (input → Municípios — cortar = Início sem thumb-zone / sem achar município até 03/08); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). **Cortes seguros** (se o prazo apertar, nesta ordem): **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); ~~**B17** seletor de colunas~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx index cd6ad9b3..82b25688 100644 --- a/src/app/(campaign)/campanha/(app)/page.tsx +++ b/src/app/(campaign)/campanha/(app)/page.tsx @@ -1,10 +1,24 @@ +import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { isStaffCampaignRole } from '@/lib/campaignRoles' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { buildMunicipalityListHref } from '@/utilities/municipality/municipalityListUrl' export const dynamic = 'force-dynamic' export default async function CampaignHomePage() { - await requireCampaignPageActor() + const user = await requireCampaignPageActor() - return {null} + const uncoveredMunicipalitiesHref = isStaffCampaignRole(user.role) + ? buildMunicipalityListHref({ page: 1, coverage: 'sem_assessor', sort: 'votos' }, 1) + : undefined + + return ( + + + + ) } diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index accc28b5..6e7e77e5 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -17,6 +17,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCoarsePointer } from '@/lib/campaignCoarsePointer' import { useCampaignLongPress } from '@/lib/campaignLongPress' +import { cn } from '@/lib/utils' export type CampaignHomeActionButtonProps = { label: string @@ -69,6 +70,7 @@ export const CampaignHomeActionButton = ({ ) const useLongPress = Boolean(description) && isCoarsePointer && !disabled + const isInert = !disabled && !href && !onClick const longPress = useCampaignLongPress({ enabled: useLongPress, onLongPress: openDescription, @@ -76,7 +78,7 @@ export const CampaignHomeActionButton = ({ }) const pointerHandlers = useLongPress ? longPress : undefined - const className = actionControlClassName + const className = cn(actionControlClassName, isInert && 'cursor-default') const aria = { 'aria-label': label, 'aria-describedby': drawerOpen && useLongPress ? descriptionId : undefined, diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx index b8441b32..29491f2c 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx @@ -16,7 +16,7 @@ export const CampaignHomeActionStrip = ({ className, }: { children?: ReactNode - actions?: readonly CampaignHomeActionButtonProps[] + actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[] ariaLabel?: string className?: string }) => ( @@ -30,9 +30,9 @@ export const CampaignHomeActionStrip = ({ > diff --git a/src/components/campaign/dashboard/CampaignHomeActions.tsx b/src/components/campaign/dashboard/CampaignHomeActions.tsx new file mode 100644 index 00000000..6429b0c6 --- /dev/null +++ b/src/components/campaign/dashboard/CampaignHomeActions.tsx @@ -0,0 +1,28 @@ +'use client' + +import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' +import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions' +import type { CampaignRole } from '@/lib/campaignRoles' + +const homeActionsHeadingId = 'campaign-home-actions-heading' + +export const CampaignHomeActions = ({ + role, + uncoveredMunicipalitiesHref, +}: { + role: CampaignRole + uncoveredMunicipalitiesHref?: string +}) => { + const actions = toHomeActionButtonProps(homeActionsForRole(role), uncoveredMunicipalitiesHref) + + if (actions.length === 0) return null + + return ( +
+

+ O que você quer fazer? +

+ +
+ ) +} diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts index be477a2d..f3462ea4 100644 --- a/src/components/campaign/shell/nav.ts +++ b/src/components/campaign/shell/nav.ts @@ -13,6 +13,7 @@ import { type LucideIcon, } from 'lucide-react' +import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import type { CampaignUser } from '@/payload-types' import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi' @@ -59,7 +60,7 @@ const staffSecondaryNav: CampaignNavItem[] = [ const leaderNav: CampaignNavItem[] = [ { title: 'Início', href: '/campanha', icon: HomeIcon }, - { title: 'Meus contatos', href: '/campanha/contatos', icon: UsersIcon }, + { title: 'Meus contatos', href: LEADER_CONTACTS_HOME, icon: UsersIcon }, ] export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => { diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts new file mode 100644 index 00000000..db29c2c3 --- /dev/null +++ b/src/lib/campaignHomeActions.ts @@ -0,0 +1,132 @@ +import type { LucideIcon } from 'lucide-react' +import { + BarChart3, + Handshake, + Inbox, + Megaphone, + TrendingUp, + UserPlus, + UserRoundSearch, + Users, +} from 'lucide-react' + +import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' +import type { CampaignRole } from '@/lib/campaignRoles' +import { isStaffCampaignRole } from '@/lib/campaignRoles' + +type CampaignHomeActionId = + | 'update-votes' + | 'register-signal' + | 'change-trend' + | 'update-leadership' + | 'register-demand' + | 'uncovered-municipalities' + | 'register-supporter' + | 'my-contacts' + +export type CampaignHomeAction = { + id: CampaignHomeActionId + label: string + icon: LucideIcon + description: string +} + +const staffHomeActionsCoordinator: readonly CampaignHomeAction[] = [ + { + id: 'update-votes', + label: 'Atualizar votos de um município', + icon: BarChart3, + description: 'Atualizar a projeção (média, pessimista, otimista) de um município', + }, + { + id: 'register-signal', + label: 'Registrar o que aconteceu', + icon: Megaphone, + description: 'Anotar sinal urgente: invasão, perda de apoio, novo apoio, dificuldade', + }, + { + id: 'change-trend', + label: 'Mudar tendência', + icon: TrendingUp, + description: 'Favorável, neutra ou desfavorável — com o porquê', + }, + { + id: 'update-leadership', + label: 'Atualizar liderança', + icon: Handshake, + description: 'Trocar quem coordena, status de apoio ou votos que a pessoa declara', + }, + { + id: 'register-demand', + label: 'Registrar pedido', + icon: Inbox, + description: 'Demanda de material, transporte, diária ou outro pedido do município', + }, + { + id: 'uncovered-municipalities', + label: 'Ver quem ainda não está coberto', + icon: UserRoundSearch, + description: 'Abrir a lista de municípios sem assessor (atalho — não é wizard)', + }, +] + +const portfolioScopeSuffix = ' nos municípios da sua carteira' + +const staffHomeActionsAdvisor: readonly CampaignHomeAction[] = staffHomeActionsCoordinator.map( + (action) => { + if (action.id === 'uncovered-municipalities') { + return { + ...action, + description: + 'Abrir a lista de municípios sem assessor da sua carteira (atalho — não é wizard)', + } + } + return { + ...action, + description: `${action.description}${portfolioScopeSuffix}`, + } + }, +) + +const leaderHomeActions: readonly CampaignHomeAction[] = [ + { + id: 'register-supporter', + label: 'Cadastrar apoiador', + icon: UserPlus, + description: 'Registrar alguém da sua rede que apoia o Solla', + }, + { + id: 'my-contacts', + label: 'Ver meus contatos', + icon: Users, + description: 'Abrir a lista dos apoiadores que você cadastrou', + }, +] + +export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => { + if (role === 'leader') return leaderHomeActions + if (!isStaffCampaignRole(role)) return [] + if (role === 'advisor') return staffHomeActionsAdvisor + return staffHomeActionsCoordinator +} + +export type ResolvedCampaignHomeAction = CampaignHomeAction & { + href?: string +} + +export const toHomeActionButtonProps = ( + actions: readonly CampaignHomeAction[], + uncoveredMunicipalitiesHref?: string, +): ResolvedCampaignHomeAction[] => + actions.map((action) => { + let href: string | undefined + if (action.id === 'my-contacts') { + href = LEADER_CONTACTS_HOME + } else if (action.id === 'uncovered-municipalities') { + href = uncoveredMunicipalitiesHref + } + return { + ...action, + ...(href ? { href } : {}), + } + }) diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts new file mode 100644 index 00000000..3cc316aa --- /dev/null +++ b/src/lib/campaignPaths.ts @@ -0,0 +1,2 @@ +/** Client-safe `/campanha` route paths shared by nav, gates, and home actions. */ +export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const diff --git a/src/utilities/campaignPageActor.ts b/src/utilities/campaignPageActor.ts index 57e2a4f1..503ac149 100644 --- a/src/utilities/campaignPageActor.ts +++ b/src/utilities/campaignPageActor.ts @@ -2,6 +2,7 @@ import 'server-only' import { redirect } from 'next/navigation' +import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' import { getCampaignUser } from '@/utilities/campaignAuth' @@ -10,7 +11,7 @@ export type CampaignPageActor = NonNullable { + test('staff sees six home actions and can open municipalities without coverage', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await expect( + page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }), + ).toBeVisible() + + for (const label of staffActionLabels) { + const link = page.getByRole('link', { name: label, exact: true }) + const button = page.getByRole('button', { name: label, exact: true }) + await expect(link.or(button)).toBeVisible() + } + + await page.getByRole('link', { name: 'Ver quem ainda não está coberto', exact: true }).click() + await page.waitForURL(/\/campanha\/municipios/) + const url = new URL(page.url()) + expect(url.searchParams.get('coverage')).toBe('sem_assessor') + expect(url.searchParams.get('sort')).toBe('votos') + }) + + test('leader sees two home actions and can open contacts', async ({ campaign, page }) => { + const { fixtures } = campaign + const leader = await fixtures.createCampaignUser('leader', { + name: fixtures.value('Liderança'), + }) + + await campaign.login(page, leader.email!, leader.password) + await expect( + page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }), + ).toBeVisible() + + await expect( + page.getByRole('button', { name: 'Cadastrar apoiador', exact: true }), + ).toBeVisible() + await expect(page.getByRole('link', { name: 'Ver meus contatos', exact: true })).toBeVisible() + + await page.getByRole('link', { name: 'Ver meus contatos', exact: true }).click() + await page.waitForURL(/\/campanha\/contatos/) + await expect(page.getByRole('heading', { name: /Meus contatos/ })).toBeVisible() + }) +}) diff --git a/tests/unit/campaignHomeActions.unit.spec.ts b/tests/unit/campaignHomeActions.unit.spec.ts new file mode 100644 index 00000000..334c1870 --- /dev/null +++ b/tests/unit/campaignHomeActions.unit.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions' +import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' + +const staffActionIds = [ + 'update-votes', + 'register-signal', + 'change-trend', + 'update-leadership', + 'register-demand', + 'uncovered-municipalities', +] as const + +const leaderActionIds = ['register-supporter', 'my-contacts'] as const + +describe('homeActionsForRole', () => { + it.each(['coordinator', 'candidate'] as const)('returns six staff actions for %s', (role) => { + const ids = homeActionsForRole(role).map((action) => action.id) + expect(ids).toEqual([...staffActionIds]) + }) + + it('returns six staff actions for advisor with portfolio-scoped descriptions', () => { + const actions = homeActionsForRole('advisor') + expect(actions.map((action) => action.id)).toEqual([...staffActionIds]) + expect( + actions.some((action) => action.description.includes('nos municípios da sua carteira')), + ).toBe(true) + const uncovered = actions.find((action) => action.id === 'uncovered-municipalities') + expect(uncovered?.description).toContain('sua carteira') + expect(uncovered?.description).not.toContain('nos municípios da sua carteira nos municípios') + }) + + it('returns two leader actions without municipality staff ids', () => { + const actions = homeActionsForRole('leader') + expect(actions.map((action) => action.id)).toEqual([...leaderActionIds]) + for (const staffId of staffActionIds) { + expect(actions.some((action) => action.id === staffId)).toBe(false) + } + }) +}) + +describe('toHomeActionButtonProps', () => { + it('wires my-contacts to the leader contacts route', () => { + const [supporter, contacts] = toHomeActionButtonProps(homeActionsForRole('leader')) + expect(supporter.href).toBeUndefined() + expect(contacts.href).toBe(LEADER_CONTACTS_HOME) + }) + + it('wires uncovered-municipalities when href is provided', () => { + const href = '/campanha/municipios?coverage=sem_assessor&sort=votos' + const props = toHomeActionButtonProps(homeActionsForRole('coordinator'), href) + const uncovered = props.find((action) => action.id === 'uncovered-municipalities') + expect(uncovered?.href).toBe(href) + const inert = props.find((action) => action.id === 'update-votes') + expect(inert?.href).toBeUndefined() + }) + + it('omits uncovered href when not provided', () => { + const props = toHomeActionButtonProps(homeActionsForRole('coordinator')) + expect(props.find((action) => action.id === 'uncovered-municipalities')?.href).toBeUndefined() + }) +})