Merge branch 'cursor/4e1d1274'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 14:03:26 -03:00
commit 69e7af7fa8
18 changed files with 578 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,117 @@
# 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.
- **`contatos/loading.tsx`** (paridade com `quadro/loading.tsx`). Revisitar se a navegação leader→contatos mostrar flash de layout em campo.
- **Auditoria de `revalidatePath('/campanha')` restantes** (sugestões/contatos já apontam para `/campanha/quadro` e `/campanha/contatos`). Revisitar se surgir helper central de revalidação de rotas `/campanha`.
## Já resolvido no simplify pós-entrega (não reabrir)
- Remoção de `isCampaignStaff` morto em `quadro/page.tsx` (gate `staff` já garante o ator).
- `LEADER_CONTACTS_HOME` / `CAMPAIGN_STAFF_QUADRO_PATH` + revalidação em `leaderSupporter` e `resolveSuggestion`.
## Explicitamente fora (triage simplify 2026-07-29)
- Constantes client-safe de href no estilo `MUNICIPALITY_NAV_HREF` (dois call sites; `nav.ts` não importa `server-only`).
- Helper e2e `gotoStaffQuadro` (três usos — DRY cosmético).
- Extrair seções compartilhadas do dashboard (um consumidor).
- Dedupar `loadMunicipalityScope` entre dashboard e sugestões (hot path pré-B43; gatilho: medição de dupla leitura em produção ou 3º consumidor).
## 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',