mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/4e1d1274'
This commit is contained in:
commit
69e7af7fa8
18 changed files with 578 additions and 176 deletions
|
|
@ -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).
|
||||
|
|
|
|||
103
docs/plans/botao-acao-inicio-strip.md
Normal file
103
docs/plans/botao-acao-inicio-strip.md
Normal 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
|
||||
124
docs/plans/catalogo-acoes-inicio-por-persona.md
Normal file
124
docs/plans/catalogo-acoes-inicio-por-persona.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
117
docs/plans/inicio-em-branco-quadro.md
Normal file
117
docs/plans/inicio-em-branco-quadro.md
Normal 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
31
src/app/(campaign)/campanha/(app)/contatos/page.tsx
Normal file
31
src/app/(campaign)/campanha/(app)/contatos/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
19
src/app/(campaign)/campanha/(app)/quadro/loading.tsx
Normal file
19
src/app/(campaign)/campanha/(app)/quadro/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/app/(campaign)/campanha/(app)/quadro/page.tsx
Normal file
116
src/app/(campaign)/campanha/(app)/quadro/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue