B43: blank Início, Quadro staff home, and Contatos for leaders.

Split dashboard and leader contacts into dedicated routes, update nav and gates, and fix revalidation paths after the route move.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 14:02:35 -03:00
parent 9c563aac8c
commit 926972fec6
18 changed files with 564 additions and 176 deletions

View file

@ -7,7 +7,8 @@ alwaysApply: false
## Status
- **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**.
- **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** botão/strip → **B45** catálogo por persona (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).

View file

@ -0,0 +1,103 @@
# Botão de ação do Início + lista horizontal
Status: rascunho
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B44 — chassis UX-1)
Impeccable: C — primitivo UI novo (botão circular + strip rolável) no Início blank
Appetite: ~1 dia eng; 2 componentes client + long-press + Tooltip/Drawer; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure; Feel the action; anti spreadsheet/dashboard cards) / `DESIGN.md` (tokens `muted` / `foreground` / `primary` — **não** inventar cinza fora do tema) · `CampaignHoverTooltip` + `Drawer` shadcn · tema `campaign`.
Na implementação: **shape → craft → critique → polish** (classe C).
Brief compacto:
- **Persona / contexto:** CG no notebook e no celular; descobre o próximo gesto **above-the-fold** sem rolar a página.
- **Job principal:** um alvo tocável (círculo + rótulo) que comunica a ação; opcionalmente explica no hover / long-press **sem** disparar a ação no long-press.
- **Estratégia de cor:** Restrained — círculo `bg-muted` (cinza claro do tema), ícone e texto `foreground` / preto do tema; sem Signal Red no idle.
- **Edit where you see:** não — launcher, não editor.
- **Anti-goals:** card com borda/sombra envolvendo o botão; ícone sem verbo; scroll do `body` quando a strip estoura; Tooltip Radix no lugar do Drawer em coarse pointer; long-press = click.
## Dados → decisão → apresentação
Dados: N/A — chrome de ação; catálogo de rótulos é **B45**.
## Contexto
Com **B43**, `/campanha` está vazio. O rascunho UX-1 pede bloco “O que você quer fazer?” com 4–6 botões. Este item entrega o **primitivo reutilizável** e a **strip horizontal** com scroll interno — ainda sem catálogo por role (B45) e sem wizards.
Pedido (2026-07-29): círculo cinza claro + texto preto; ícone no centro; texto descritivo abaixo; área inteira clicável; lista horizontal com drag/scroll interno; descrição opcional em hover (desktop/tablet = tooltip) ou long-press (mobile = bottom drawer); long-press não dispara o click/tap.
## Objetivos
- Componente **`CampaignHomeActionButton`** (nome final no PR) com props: `label`, `icon` (Lucide), `description?`, `onClick?` / `href?`, `disabled?`.
- Visual: círculo claro + ícone centrado + label abaixo; hit target = círculo **e** label (um só controle focável).
- **`CampaignHomeActionStrip`**: fila horizontal; `overflow-x-auto` (não `overflow` no body); preferir scroll-snap leve; touch-pan-x; scrollbar discreta ou hidden com affordance de overflow.
- Descrição opcional:
- `pointer-fine` + hover/focus → Tooltip (reusar stack shadcn; **não** reutilizar às cegas `CampaignHoverTooltip` se o contrato touch-toggle conflitar com long-press — medir e documentar).
- `pointer-coarse` → long-press abre **Drawer** inferior com título = label + corpo = description; soltar após limiar **não** chama `onClick`.
- Sem migration / collection / Consent / action de escrita.
- Unit (jsdom): long-press não dispara click; strip não propaga scroll vertical do body (smoke); a11y name = label (+ description via `aria-describedby` quando aberta).
## Decisões travadas
- **Um botão = um controle**, não círculo link + texto link separados. **Rejeitado:** dois alvos (ícone vs texto) — falha de hit area e SC 2.5.3.
- **Long-press ≠ click.** Timer ~400–500 ms; `pointercancel` / move além de slop cancela; no fire do long-press, `preventDefault` no click sintético seguinte. **Rejeitado:** tap curto abre Drawer (rouba a ação futura); só `title=` nativo (inacessível no touch).
- **Tooltip fine / Drawer coarse** — espelha B34/B42 (política por pointer, não só viewport). **Rejeitado:** um único Popover; hover no mobile.
- **Cores via tokens** (`bg-muted`, `text-foreground`) — “cinza claro / preto” do pedido mapeiam ao tema, não hex soltos. **Rejeitado:** `#e5e5e5` hardcoded.
- **i18n:** ids/props em inglês (`label`, `description`, `HomeActionButton`); copy pt-BR vem do catálogo B45.
## Questões em aberto
- **Scroll com drag de mouse (desktop) além da wheel/trackpad?** **Opções:** A só overflow nativo | B drag-to-scroll. **Recomendação:** A no v1 — nativo basta; drag é polish se sessão reclamar. _(assumido)_
## Abordagem proposta
```mermaid
flowchart TB
Strip["CampaignHomeActionStrip<br/>overflow-x-auto"]
Btn["CampaignHomeActionButton"]
Tip["Tooltip fine pointer"]
Draw["Drawer coarse + long-press"]
Strip --> Btn
Btn --> Tip
Btn --> Draw
```
Componentes:
- **`src/components/campaign/dashboard/CampaignHomeActionButton.tsx`** (`'use client'`): botão/`Link` polimórfico; círculo `size-*` com ícone; label `text-sm` centrado; long-press machine; Tooltip vs Drawer.
- **`src/components/campaign/dashboard/CampaignHomeActionStrip.tsx`**: lista `role="list"` / items; classes de overflow; opcional fade edges (Adiado se custar).
- **Não** montar no Início com dados reais aqui — story/fixture no plano de teste **ou** mount placeholder só em storybook-less: B45 monta. Em B44, e2e/unit montam os componentes isolados **ou** uma página de smoke se necessário; preferir specs de componente.
- **Migration:** Sem migration, sem collection, sem server action.
## Dependências
- Dura: **B43** (Início blank onde a strip pousa — pode desenvolver isolado, mas aceitação visual é no Início).
- Soft: padrão Drawer de `CampaignCellEditOverlay` / `ui/Drawer`.
## Não escopo
- Catálogo por role e mount no Início → **B45**.
- Navegação/wizards reais → fatias UX-1 seguintes.
- Redesign do Quadro.
## Rabbit holes
- **Generalizar `CampaignHoverTooltip` para long-press+Drawer.** 3º comportamento num componente já denso. **Mitigação:** lógica no botão do Início; extrair só no 2º call site idêntico.
- **Carousel com setas / lib de swipe.** **Mitigação:** overflow nativo.
## Adiado com gatilho
- **Drag-to-scroll no desktop.** Revisitar se CG não descobrir overflow na strip.
- **Fade edges** na strip. Revisitar no critique Impeccable se o corte visual mentir “acabou”.
## Referências
- `docs/roadmap.md` (B44) · [inicio-em-branco-quadro.md](inicio-em-branco-quadro.md) (B43) · [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)
- `src/components/ui/Drawer.tsx` · `tooltip.tsx` · `CampaignHoverTooltip.tsx` (contrato touch — comparar, não copiar cego)
- `PRODUCT.md` / `DESIGN.md`
- AGENTS.md — client boundary; Feel the action

View file

@ -0,0 +1,124 @@
# Catálogo de ações do Início por persona
Status: rascunho
Atualizado em: 2026-07-29
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
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity; Depth for staff / light for leader) / `DESIGN.md` · primitivo **B44** · catálogo confirmado com produto 2026-07-29.
Na implementação: craft compacto → critique → polish.
Brief compacto:
- **Persona / contexto:** quatro roles veem o Início; cada um vê só ações do seu mundo (leader lockdown).
- **Job principal:** escolher a próxima ação pelo verbo — ainda sem executar wizards (exceto atalhos de lista/contatos).
- **Estratégia de cor:** herda B44 (Restrained).
- **Edit where you see:** não nesta fatia (launchers).
- **Anti-goals:** botões de inteligência/jargão (“LQ”, “captura”); >6 ações staff; leader vendo ações de município.
## Dados → decisão → apresentação
Dados: N/A — rótulos/ícones/descrições estáticos; sem KPI.
## Contexto
**B43** deixou `/campanha` vazio; **B44** entregou botão + strip. Falta o **conteúdo por persona** confirmado com produto (gate 2026-07-29) e o mount. Wizards com escrita ficam fora — botões inertes (`type="button"` sem handler, ou `aria-disabled` com toast “Em breve” — **recomendação:** inertes sem toast para não treinar clique inútil; `aria-disabled` + explicação só se critique exigir feedback).
Alinhado a [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) A1–A6 + lockdown leader.
## Objetivos
- Módulo client-safe `src/lib/campaignHomeActions.ts` (ou `dashboard/`) com:
- tipo `CampaignHomeAction = { id, label, icon, description, href?: string }`
- `homeActionsForRole(role): readonly CampaignHomeAction[]`
- Montar `CampaignHomeActionStrip` em `/campanha` (RSC passa a lista resolvida por `user.role`).
- **Atalhos com destino real nesta fatia:**
- staff `uncovered-municipalities` → `buildMunicipalityListHref({ coverage: 'sem_assessor', sort: 'votos' }, 1)` (serializar no server — não importar o builder no client strip se arrastar catálogo; passar `href` já pronto).
- leader `my-contacts` → `/campanha/contatos`
- Demais ações: **sem** `href`/`onClick` (inerte).
- Assessor: mesmos ids/rótulos; descriptions com “nos municípios da sua carteira” onde couber.
- Sem migration / Consent / action de escrita.
## Catálogo confirmado (produto 2026-07-29)
### Coordenador Geral e Candidato
| id | Rótulo | Ícone | Descrição |
| -------------------------- | ------------------------------- | ----------------- | ---------------------------------------------------------------------- |
| `update-votes` | Atualizar votos de um município | `BarChart3` | Atualizar a projeção (média, pessimista, otimista) de um município |
| `register-signal` | Registrar o que aconteceu | `Megaphone` | Anotar sinal urgente: invasão, perda de apoio, novo apoio, dificuldade |
| `change-trend` | Mudar tendência | `TrendingUp` | Favorável, neutra ou desfavorável — com o porquê |
| `update-leadership` | Atualizar liderança | `Handshake` | Trocar quem coordena, status de apoio ou votos que a pessoa declara |
| `register-demand` | Registrar pedido | `Inbox` | Demanda de material, transporte, diária ou outro pedido do município |
| `uncovered-municipalities` | Ver quem ainda não está coberto | `UserRoundSearch` | Abrir a lista de municípios sem assessor (atalho — não é wizard) |
### Assessor
Mesmos 6 ids/rótulos/ícones. Descrições: acrescentar escopo de carteira (ex.: “…nos municípios da sua carteira”). `uncovered-municipalities` usa o mesmo href; access da lista já filtra.
### Liderança
| id | Rótulo | Ícone | Descrição |
| -------------------- | ------------------ | ---------- | ----------------------------------------------- |
| `register-supporter` | Cadastrar apoiador | `UserPlus` | Registrar alguém da sua rede que apoia o Solla |
| `my-contacts` | Ver meus contatos | `Users` | Abrir a lista dos apoiadores que você cadastrou |
## Decisões travadas
- **Catálogo em `lib/` puro**, não Payload. **Rejeitado:** collection de “ações” (caro, zero ganho até wizards).
- **CG ≡ candidate** no conjunto de botões (`isCampaignUnrestricted` / ambos staff unrestricted). **Rejeitado:** botões só no CG (candidate tem a mesma mesa).
- **Dois botões na liderança; `my-contacts` navega já.** **Rejeitado:** só um botão; os dois inertes (contatos já têm rota B43 — esconder o atalho reintroduce atrito).
- **`uncovered-municipalities` navega já** (A6 do rascunho — não é wizard). **Rejeitado:** esperar wizard chassis.
- **i18n:** ids estáveis em inglês (`update-votes`, …); labels pt-BR no catálogo.
## Questões em aberto
- **Feedback ao tocar ação inerte?** **Opções:** A silêncio | B toast “Em breve”. **Recomendação:** A — critique pode pedir B. _(assumido — validar no polish)_
## Abordagem proposta
```mermaid
flowchart LR
Page["/campanha RSC"] --> Cat["homeActionsForRole(role)"]
Cat --> Strip["CampaignHomeActionStrip"]
Strip --> Btn["CampaignHomeActionButton × N"]
```
Componentes:
- **`src/lib/campaignHomeActions.ts`:** catálogo + `homeActionsForRole`.
- **`page.tsx` (Início):** após B43 blank, monta strip com actions do ator; resolve hrefs server-side.
- **Testes:** unit do catálogo (cada role → ids esperados; leader sem ids de município; staff tem `uncovered-municipalities` com href shape); e2e smoke: Início staff mostra 6 botões; leader 2; click em “Ver meus contatos” abre `/campanha/contatos`.
- **Migration:** Sem migration, sem collection, sem server action.
## Dependências
- Duras: **B43**, **B44**.
- Soft: rascunho UX-1; `buildMunicipalityListHref`.
## Não escopo
- Chassis/wizard de passos e escrita A1–A5 → próximos itens UX-1 (registrar após B45).
- Ligar E11 → wizards.
- Voltar mapa/KPI à 1ª dobra.
## Rabbit holes
- **Feature-flag por ação / remote config.** **Mitigação:** const no `lib/` até o 2º mandato precisar.
- **Toast “Em breve” + analytics por idle click.** **Mitigação:** silêncio até critique.
## Adiado com gatilho
- **Wire real de cada id → rota de wizard.** Revisitar item a item (A1 votos primeiro — ordem do rascunho UX-1).
- **Toast em inerte.** Revisitar se sessão mostrar taps repetidos confusos.
## Referências
- `docs/roadmap.md` (B45) · [inicio-em-branco-quadro.md](inicio-em-branco-quadro.md) · [botao-acao-inicio-strip.md](botao-acao-inicio-strip.md) · [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)
- `src/utilities/municipality/municipalityListUrl.ts` (`coverage: 'sem_assessor'`)
- `PRODUCT.md` / `DESIGN.md` · AGENTS.md — roles / lockdown leader

View file

@ -206,16 +206,24 @@ Não competir com os botões.
---
## Ordem de entrega sugerida (ainda planejamento)
## Ordem de entrega sugerida
1. **Chassis do wizard** (shell de passos + busca de município + CTA final + resumo)
2. **A1** (votos) com “quer também?” sinal + tendência
3. **A6** atalho + tabela above-the-fold na lista
4. **A2** standalone (se não ficou bom só embutido)
5. **A4** curto (status / observação / quem coordena)
6. **A5** + edição de demanda
7. Ligar E11 → wizards
8. Copy pass + A3 standalone se ainda fizer falta
### Chassis do Início (registrado 2026-07-29)
0. **B43** Início em branco + Quadro/Contatos — [inicio-em-branco-quadro.md](inicio-em-branco-quadro.md)
1. **B44** Botão circular + strip horizontal — [botao-acao-inicio-strip.md](botao-acao-inicio-strip.md)
2. **B45** Catálogo por persona + botões (atalhos reais; wizards ainda inertes) — [catalogo-acoes-inicio-por-persona.md](catalogo-acoes-inicio-por-persona.md)
### Wizards (ainda planejamento — IDs depois de B45)
3. **Chassis do wizard** (shell de passos + busca de município + CTA final + resumo)
4. **A1** (votos) com “quer também?” sinal + tendência
5. **A6** — atalho já parcialmente em B45 (`uncovered-municipalities`); tabela above-the-fold na lista se ainda faltar
6. **A2** standalone (se não ficou bom só embutido)
7. **A4** curto (status / observação / quem coordena)
8. **A5** + edição de demanda
9. Ligar E11 → wizards
10. Copy pass + A3 standalone se ainda fizer falta
---

View file

@ -0,0 +1,103 @@
# Início em branco + Quadro (preservar dashboard)
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: redirect de leader nos gates → `/campanha/contatos`; loading do quadro em `quadro/loading.tsx`; Início com `CampaignPageShell` vazio.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B43 — chassis UX-1)
Impeccable: B — relocate do dashboard existente + Início intencionalmente vazio sob tema `campaign`
Appetite: ~0,5 dia eng; move de rota + nav + blank home; sem migration
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2 Clarity under pressure, 5 Depth/simplicity — leader path; anti-goals §5 dashboard SaaS) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shell `CampaignPageShell` / `nav.ts`.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish. `harden`/`optimize` só se Passo 8 acionar gatilho.
Brief compacto:
- **Persona / contexto:** CG (e demais papéis) abrem o app sob pressão de Zap; a tela atual começa por mapa/KPI e compete com o ritual Ação→Local→Quem.
- **Job principal:** `/campanha` vira superfície vazia pronta para o bloco de ações (B44/B45); o quadro operacional sobrevive intacto em URL própria.
- **Estratégia de cor:** Restrained.
- **Edit where you see:** não — só relocate / blank; sem edição nova.
- **Anti-goals:** redesign do dashboard nesta fatia; saudação/título no Início “só um pouquinho”; segundo home paralelo; quebrar o lockdown da liderança (contatos sem destino).
## Dados → decisão → apresentação
Dados: N/A — relocate e blank; nenhum número novo nesta entrega.
## Contexto
O Início staff (`src/app/(campaign)/campanha/(app)/page.tsx` → `CampaignDashboard`) monta mapa, KPIs, sugestões E11, prioritários, visitados, “Onde estou”. A liderança recebe `LeaderContactsPanel` no mesmo path. A sessão observada 2026-07-29 e o rascunho [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) (UX-1) pedem **ações first**; o quadro atual continua útil como **briefing / gestão**, não como porta de entrada.
Pedido de produto (2026-07-29): primeira tarefa do remodel — estruturar sem perder o que existe (exemplo vivo), com Início **completamente em branco** (só sidebar).
## Objetivos
- `/campanha` renderiza só o chrome do `(app)` (sidebar / bottom nav) — **zero** `h1`, subtítulo, mapa, KPI, slots.
- Dashboard staff atual move para **`/campanha/quadro`** com comportamento byte-equivalente (mesmos loaders/slots).
- Contatos da liderança move para **`/campanha/contatos`** (hoje vivem no Início).
- Sidebar / bottom nav: entrada **Quadro** (staff); liderança ganha **Início** + **Meus contatos** (hrefs distintos — sem React key duplicada).
- Sem migration, collection, Consent, server action nova.
- `isCampaignNavActive('/campanha')` continua match exato (não engole `/quadro` / `/contatos`).
## Decisões travadas
- **`/campanha/quadro` para o dashboard staff.** Copy da UI já fala “Quadro geral / dos municípios”. **Rejeitado:** `/visao-geral` (mais longo); `/inicio-antigo` (temporário no URL); deixar dashboard em `/campanha` e ações em subrota (contradiz o pedido de Início blank).
- **`/campanha/contatos` para `LeaderContactsPanel`.** O blank do Início **não** pode orphanar a única ferramenta da liderança. **Rejeitado:** manter contatos no Início “só para leader” (dois Inícios mentais); esconder contatos até B45 (lockdown quebrado por um dia).
- **Início literalmente vazio** — sem saudação. **Rejeitado:** “Olá, {nome}” residual (vira âncora visual que o bloco de ações depois displace).
- **i18n:** identificadores `quadro` / `contatos` nas pastas de rota; strings “Quadro” / “Meus contatos” / “Início” em pt-BR.
## Questões em aberto
- **Redirect de bookmarks `/campanha` que esperavam o mapa?** **Opções:** A nada | B redirect staff→`/quadro`. **Recomendação:** A — o Início novo é o destino; Quadro está a um clique na sidebar. _(assumido)_
## Abordagem proposta
```mermaid
flowchart LR
Home["/campanha page.tsx<br/>blank shell"]
Quadro["/campanha/quadro<br/>CampaignDashboard + slots"]
Contatos["/campanha/contatos<br/>LeaderContactsPanel"]
Nav["nav.ts<br/>Início + Quadro / Contatos"]
Home --> Nav
Quadro --> Nav
Contatos --> Nav
```
Componentes:
- **`page.tsx` (Início)** — `requireCampaignPageActor()`; return `<CampaignPageShell />` vazio (ou fragmento sem filhos de conteúdo). Sem branch leader/staff de dados.
- **`quadro/page.tsx` (novo)** — corpo atual do home staff (dashboard + Suspense map/suggestions). Gate: staff only; leader → redirect `/campanha/contatos` ou `/campanha`.
- **`contatos/page.tsx` (novo)** — corpo atual do branch `isCampaignLeader` + `LeaderContactsPanel`. Staff que abrir → redirect `/campanha` ou 404 de rota (recomendação: redirect `/campanha`).
- **`nav.ts`:** staff: Início `/campanha`, Quadro `/campanha/quadro` (ícone ex. `LayoutDashboard`), demais intactos. Leader: Início `/campanha` + Meus contatos `/campanha/contatos`.
- **`getCampaignBottomNav`:** incluir Quadro ou não no cap de 5 — **recomendação:** manter Início + municípios…; Quadro só sidebar no mobile (mapa/KPI não é ritual de polegar). Documentar no PR.
- **Migration:** Sem migration, sem collection, sem server action.
## Dependências
- Nenhuma de outro plano duro. Soft: UX-1 / [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md). Desbloqueia **B44** / **B45**.
## Não escopo
- Bloco de ações / botões → **B44** / **B45**.
- Wizards com escrita → fatias posteriores de UX-1.
- Redesign visual do `CampaignDashboard`.
- Mover mapa/KPIs de volta ao Início “abaixo das ações” (2ª dobra do rascunho UX-1 — gatilho pós-B45 + uso real).
## Rabbit holes
- **Reescrever `CampaignDashboard` “já que estamos mexendo”.** Explosão de polish. **Mitigação:** move mecânico; zero mudança de markup do quadro.
- **Unificar Quadro+Contatos numa rota com `?role=`.** Confunde URL e access. **Mitigação:** duas rotas explícitas.
## Adiado com gatilho
- **2ª dobra no Início** (mapa/KPI/sugestões sob as ações). Revisitar quando: B45 entregue **e** CG pedir briefing no mesmo viewport (soft 03/08).
- **Quadro no bottom nav mobile.** Revisitar se analytics/sessão mostrarem acesso frequente ao mapa no telefone.
## Referências
- `docs/roadmap.md` (UX-1; B43)
- [fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) · [sessao-observada-coordenador-2026-07-29-snapshot.md](sessao-observada-coordenador-2026-07-29-snapshot.md)
- `src/app/(campaign)/campanha/(app)/page.tsx` · `CampaignDashboard.tsx` · `LeaderContactsPanel.tsx` · `shell/nav.ts`
- `PRODUCT.md` / `DESIGN.md` — Field Desk
- AGENTS.md — Campaign auth; naming (URL pt-BR / ids inglês)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,31 @@
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
export const dynamic = 'force-dynamic'
export default async function CampaignContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
if (!isCampaignLeader(user)) {
redirect('/campanha')
}
const view = await loadLeaderContactsPageData(payload, user)
return (
<LeaderContactsPanel
userName={user.name}
municipalityOptions={view.municipalityOptions}
defaultMunicipalityId={view.defaultMunicipalityId}
showMunicipalitySelect={view.showMunicipalitySelect}
registrationConsentConfigured={view.registrationConsentConfigured}
contacts={view.contacts}
/>
)
}

View file

@ -1,19 +1,9 @@
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
export default function CampaignDashboardLoading() {
export default function CampaignHomeLoading() {
return (
<CampaignPageShell aria-label="Carregando painel da campanha" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
<div className="flex flex-col gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
<CampaignPageShell aria-busy="true" aria-label="Carregando início">
{null}
</CampaignPageShell>
)
}

View file

@ -1,139 +1,10 @@
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { Suspense } from 'react'
import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashboard'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import {
SuggestionSilenceStrip,
SuggestionsPanel,
} from '@/components/campaign/suggestion/SuggestionsPanel'
import type { CampaignUser } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import { resolveSuggestionFormAction } from './suggestionFormActions'
export const dynamic = 'force-dynamic'
type CampaignHomePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function CampaignHomePage({ searchParams }: CampaignHomePageProps) {
const rawSearchParams = await searchParams
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
if (isCampaignLeader(user)) {
const view = await loadLeaderContactsPageData(payload, user)
return (
<LeaderContactsPanel
userName={user.name}
municipalityOptions={view.municipalityOptions}
defaultMunicipalityId={view.defaultMunicipalityId}
showMunicipalitySelect={view.showMunicipalitySelect}
registrationConsentConfigured={view.registrationConsentConfigured}
contacts={view.contacts}
/>
)
}
const isStaff = isCampaignStaff(user)
const view = await getCampaignDashboardData(payload, user)
return (
<CampaignDashboard
view={view}
userName={user.name}
mapSlot={
isStaff ? (
// Streams after the KPI shell paints; the shared request-scoped
// municipality scope is reused (React cache), not reloaded.
<Suspense
fallback={
<div
aria-hidden="true"
className="h-[420px] w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardMapSection payload={payload} user={user} compare={rawSearchParams.compare} />
</Suspense>
) : null
}
suggestionsSlot={
isStaff ? (
// Streams too: the evaluator adds its own reads (signals, agenda,
// decisions) and must not hold the KPI shell.
<Suspense
fallback={
<div
aria-hidden="true"
className="h-48 w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardSuggestionsSection payload={payload} user={user} />
</Suspense>
) : null
}
/>
)
}
/** E11 — the decision queue: top-5 by triage plus the monthly silence review. */
const DashboardSuggestionsSection = async ({
payload,
user,
}: {
payload: Payload
user: CampaignUser
}) => {
const bundle = await loadMunicipalitySuggestions(payload, user)
return (
<SuggestionsPanel
titleId="dashboard-suggestions-title"
suggestions={bundle.suggestions.slice(0, 5)}
activeCount={bundle.suggestions.length}
showMunicipality
resolveAction={resolveSuggestionFormAction}
emptyState={
<p className="text-sm text-muted-foreground">
Nenhum padrão do catálogo dispara nos seus municípios agora.
</p>
}
>
<SuggestionSilenceStrip entries={bundle.silence} />
</SuggestionsPanel>
)
}
const DashboardMapSection = async ({
payload,
user,
compare,
}: {
payload: Payload
user: CampaignUser
compare: string | string[] | undefined
}) => {
const [mapBundle, candidateOptions] = await Promise.all([
loadMunicipalityMapBundle(payload, user, { compare }),
loadFederalCandidateOptions(user),
])
if (!mapBundle) return null
return (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
)
export default async function CampaignHomePage() {
await requireCampaignPageActor()
return <CampaignPageShell aria-label="Início">{null}</CampaignPageShell>
}

View file

@ -0,0 +1,19 @@
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
export default function CampaignQuadroLoading() {
return (
<CampaignPageShell aria-label="Carregando painel da campanha" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
<div className="flex flex-col gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,116 @@
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { Suspense } from 'react'
import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashboard'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import {
SuggestionSilenceStrip,
SuggestionsPanel,
} from '@/components/campaign/suggestion/SuggestionsPanel'
import type { CampaignUser } from '@/payload-types'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import { resolveSuggestionFormAction } from '../suggestionFormActions'
export const dynamic = 'force-dynamic'
type CampaignQuadroPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function CampaignQuadroPage({ searchParams }: CampaignQuadroPageProps) {
const rawSearchParams = await searchParams
const [payload, user] = await Promise.all([
getPayload({ config }),
requireCampaignPageActor({ gate: 'staff' }),
])
const view = await getCampaignDashboardData(payload, user)
return (
<CampaignDashboard
view={view}
userName={user.name}
mapSlot={
<Suspense
fallback={
<div
aria-hidden="true"
className="h-[420px] w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardMapSection payload={payload} user={user} compare={rawSearchParams.compare} />
</Suspense>
}
suggestionsSlot={
<Suspense
fallback={
<div
aria-hidden="true"
className="h-48 w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardSuggestionsSection payload={payload} user={user} />
</Suspense>
}
/>
)
}
/** E11 — the decision queue: top-5 by triage plus the monthly silence review. */
const DashboardSuggestionsSection = async ({
payload,
user,
}: {
payload: Payload
user: CampaignUser
}) => {
const bundle = await loadMunicipalitySuggestions(payload, user)
return (
<SuggestionsPanel
titleId="dashboard-suggestions-title"
suggestions={bundle.suggestions.slice(0, 5)}
activeCount={bundle.suggestions.length}
showMunicipality
resolveAction={resolveSuggestionFormAction}
emptyState={
<p className="text-sm text-muted-foreground">
Nenhum padrão do catálogo dispara nos seus municípios agora.
</p>
}
>
<SuggestionSilenceStrip entries={bundle.silence} />
</SuggestionsPanel>
)
}
const DashboardMapSection = async ({
payload,
user,
compare,
}: {
payload: Payload
user: CampaignUser
compare: string | string[] | undefined
}) => {
const [mapBundle, candidateOptions] = await Promise.all([
loadMunicipalityMapBundle(payload, user, { compare }),
loadFederalCandidateOptions(user),
])
if (!mapBundle) return null
return (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
)
}

View file

@ -17,6 +17,7 @@ import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/c
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { LEADER_CONTACTS_HOME } from '@/utilities/campaignPageActor'
import {
acquireContactPhoneLocks,
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
@ -176,7 +177,7 @@ export const createLeaderSupporterFormAction = async (
})
const supporter = await createLeaderSupporter(input)
revalidatePath('/campanha')
revalidatePath(LEADER_CONTACTS_HOME)
return {
status: 'success',

View file

@ -18,6 +18,7 @@ import { DAY_MS } from '@/lib/text'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { AllocationDecision, CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { CAMPAIGN_STAFF_QUADRO_PATH } from '@/utilities/campaignPageActor'
import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
@ -144,7 +145,7 @@ export const resolveSuggestion = async (
// The two surfaces that render the queue: the staff dashboard panel and the
// município detail card.
revalidatePath('/campanha')
revalidatePath(CAMPAIGN_STAFF_QUADRO_PATH)
revalidatePath(`/campanha/municipios/${municipalitySlug}`)
return { postponeDays }

View file

@ -4,6 +4,7 @@ import {
HandshakeIcon,
HomeIcon,
InboxIcon,
LayoutDashboardIcon,
MapIcon,
MapPinIcon,
UserCogIcon,
@ -31,6 +32,7 @@ export const MUNICIPALITY_NAV_HREF = '/campanha/municipios'
const staffNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Quadro', href: '/campanha/quadro', icon: LayoutDashboardIcon },
{ title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon },
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
@ -56,9 +58,8 @@ const staffSecondaryNav: CampaignNavItem[] = [
]
const leaderNav: CampaignNavItem[] = [
// The leader home IS the contact tool — one entry, one href (duplicate
// hrefs previously produced duplicate React keys in the sidebar).
{ title: 'Meus contatos', href: '/campanha', icon: UsersIcon },
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Meus contatos', href: '/campanha/contatos', icon: UsersIcon },
]
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => {
@ -85,6 +86,7 @@ export const getCampaignBottomNav = (role: CampaignUser['role']): CampaignNavIte
return nav
.filter(
(item) =>
item.href !== '/campanha/quadro' &&
item.href !== '/campanha/territorios' &&
item.href !== '/campanha/apoiadores' &&
item.href !== '/campanha/assessores',

View file

@ -10,15 +10,20 @@ export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaig
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader'
export const LEADER_CONTACTS_HOME = '/campanha/contatos'
/** Staff dashboard (B43 — moved off blank `/campanha`). */
export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
/**
* THE staff page prologue (Pass 3 P3-I — was ~110 hand-spelled lines across 30
* pages, with a real divergence: 5 pages answered a missing session with
* `return null` — a blank screen — instead of redirecting to login). Gates:
*
* - (none) → any authenticated campaign user;
* - 'staff' → non-staff (leader) goes to `/campanha`;
* - 'staff' → non-staff (leader) goes to `/campanha/contatos` (B43);
* - 'unrestricted' → non-coordinator/candidate goes to `/campanha`;
* - 'noLeader' → leader goes to `/campanha` (their home is the contact tool).
* - 'noLeader' → leader goes to `/campanha/contatos` (B43).
*
* A custom `redirectTo` overrides the gate's default target. The convention
* guard in `codebaseConventions.unit.spec.ts` fails the build on a
@ -30,10 +35,12 @@ export const requireCampaignPageActor = async (
const user = await getCampaignUser()
if (!user) redirect('/campanha/login')
const { gate, redirectTo = '/campanha' } = options
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(redirectTo)
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(redirectTo)
if (gate === 'noLeader' && user.role === 'leader') redirect(redirectTo)
const { gate, redirectTo } = options
const denyRedirect = redirectTo ?? (user.role === 'leader' ? LEADER_CONTACTS_HOME : '/campanha')
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
return user
}

View file

@ -66,7 +66,7 @@ test.describe('Conceitos de inteligência', () => {
await page.goto('/campanha/conceitos')
await expect(page).toHaveURL(/\/campanha\/?$/)
await expect(page).toHaveURL(/\/campanha\/contatos\/?$/)
await expect(
page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
).toHaveCount(0)

View file

@ -150,6 +150,7 @@ test.describe('Município mais próximo', () => {
await page.context().setGeolocation({ latitude: inside.lat, longitude: inside.lng })
await campaign.login(page, email, password)
await page.goto(`${campaign.baseURL}/campanha/quadro`)
const card = geoCard(page)
await expect(visibleText(card, `Você está em ${municipality.name}`)).toBeVisible()
@ -190,6 +191,7 @@ test.describe('Município mais próximo', () => {
})
await campaign.login(page, email, password)
await page.goto(`${campaign.baseURL}/campanha/quadro`)
const card = geoCard(page)
await expect(visibleText(card, zone.name)).toBeVisible()
@ -213,6 +215,7 @@ test.describe('Município mais próximo', () => {
// No grantPermissions: Chromium refuses the position, the same as a user tapping "Block".
await campaign.login(page, email, password)
await page.goto(`${campaign.baseURL}/campanha/quadro`)
const card = geoCard(page)
await expect(visibleText(card, 'Localização bloqueada neste navegador')).toBeVisible()

View file

@ -7,6 +7,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
for (const path of [
'/campanha/login',
'/campanha',
'/campanha/quadro',
'/campanha/municipios',
'/campanha/territorios',
'/campanha/municipios/e2e-prewarm',