Merge pull request #354 from fsolla/agent/22-acoes-rapidas-apoiadores

B86 — Ações rápidas — Apoiadores (staff): catálogo no FAB/overlay
This commit is contained in:
Francisco Solla 2026-08-03 21:14:36 -03:00 committed by GitHub
commit 8f88c93429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 359 additions and 1 deletions

View file

@ -0,0 +1,75 @@
# Impl: Ações rápidas — Apoiadores (staff)
Status: aprovado (gate humano 2026-08-03)
Atualizado em: 2026-08-03
Issue: #22
Intenção: docs/plans/acoes-rapidas-apoiadores.md
Appetite restante: ~0,25–0,5 dia eng (herdado); sem migration
## Leitura da intenção
- **Outcome:** staff em `/campanha/apoiadores` (lista e detalhe) cadastra / importa sem voltar ao Início — via o chrome de ações rápidas (FAB/overlay) já montado para staff em toda rota fora do Início e `/acoes/*`.
- **O que NÃO negociar:** staff-only nesta rota (leader → B89 Contatos, não esta rota); sem novo Consent (launchers apontam para os flows C2 existentes, que fail-closed); sem A1–A5 como primários (recomendação da intenção: **A — não incluir**); busca já vive no overlay (B91 ✓).
- **O que reavaliar:** a intenção assume "atalho import se houver URL estável" — a rota `/campanha/apoiadores/importar` existe e é estável (wizard 3 passos; apontar a ela = passo 1 = upload, sem bypass de preview — revisão obrigatória da intenção satisfeita). O gating do import no código é **coordinator-only** (`isCampaignCoordinator` na página e redirect no `importar/page.tsx`), mais restrito que `isUnrestrictedCampaignRole` — o catálogo do drawer deve espelhar esse gate.
## Abordagem recomendada
```mermaid
flowchart LR
Fab["CampaignQuickActionsHost (staff, pathname)"] --> Reg["resolveQuickActionsForPath"]
Reg --> Supp["supporterQuickActions.ts (B86)"]
Supp --> List["lista / apoiadores"]
Supp --> Detail["detalhe /apoiadores/:id (numérico)"]
List --> Cad["register-supporter → /apoiadores/novo (staff)"]
List --> Imp["import-supporters → /apoiadores/importar (coordinator)"]
Detail --> Cad
Detail --> Imp
Reg -->|"novo|importar não casam"| Empty["[] (FAB = só busca)"]
```
**Opções consideradas:** A | B | C
**Recomendação:** **A** — módulo novo `supporterQuickActions.ts` espelhando `campaignAdvisorQuickActions.ts` (B87), com superfície lista+detalhe, catálogo de 2 launchers, import gated a coordinator; registro no registry central; testes unit espelhando B87/B85.
**Rejeitadas:**
- **B — superfície inclui `/apoiadores/novo` e `/apoiadores/importar` com o mesmo catálogo:** redundante (a página já é o próprio formulário/wizard); precedente B85 exclui `nova` do detalhe e deixa a página de form com FAB só-busca. Rejeitada.
- **C — "Importar CSV" para todo staff (`isUnrestrictedCampaignRole`):** espelha a intenção mas NÃO o gate de servidor — `importar/page.tsx` redireciona não-coordinator para `/apoiadores`; o launcher levaria advisor/candidate a um redirect. O drawer deve espelhar o gate da página (coordinator-only), como o botão "Importar CSV" da lista faz.
- **D — catálogo com A1–A5 do Início (prefill de município):** rejeitado pela própria intenção (questão em aberto → recomendação A) e não há município garantido no contexto da ficha de apoiador.
### Componentes / mudanças
- **`CAMPAIGN_SUPPORTERS_HOME`** (`src/lib/campaignPaths.ts`): constante `/campanha/apoiadores`, padrão dos irmãos (`CAMPAIGN_DEMANDS_HOME`, `CAMPAIGN_ADVISORS_HOME`).
- **`supporterQuickActions.ts`** (`src/lib/`): `isSupportersListPath` (match exato), `parseSupporterDetailId` (regex `^/campanha/apoiadores/(\d+)(?:\/|$)`, como `parseAdvisorDetailId`), `isSupportersPath`, `resolveSupporterListQuickActions` / `resolveSupporterDetailQuickActions` / `resolveSupporterQuickActionsForPath`. Gate: `isStaffCampaignRole` (coordinator/advisor/candidate — igual a `canAccessSupporterArea`); `import-supporters` adicional só `role === 'coordinator'` (espelha `isCampaignCoordinator`).
- **`campaignQuickActionRegistry.ts`**: entrada nova delegando para `resolveSupporterQuickActionsForPath`, agrupada com as verticais (após advisor, antes de territórios). Sem colisão de path com outros matchers.
- **Actions:** `register-supporter` (label "Cadastrar apoiador", `UserPlus`, href `/campanha/apoiadores/novo` — id igual ao da intenção/i18n) e `import-supporters` (label "Importar CSV", `FileUp`, href `/campanha/apoiadores/importar`). `CampaignQuickAction` client-safe; overlay já renderiza via `CampaignHomeActionStrip variant="grid"`.
- **Migration:** sem migration; sem collection; sem Consent; sem server action nova.
- **Access / Consent:** role predicates client-safe (`isStaffCampaignRole` / `role === 'coordinator'`); nenhuma escrita; launchers para flows existentes fail-closed.
- **UI:** Impeccable **B** (a intenção pede "Craft + Revisão na implementação") — a superfície é o chrome existente (FAB/overlay B100+), sem shape novo; nada a renderizar além do que o registro devolve. Mount já cobre staff em `/apoiadores/**` (`shouldMountQuickActionsFab`); leader já fica de fora (mount só em Contatos).
### Dados → forma (se aplicável)
- N/A — sem dados novos; launchers de navegação (hrefs), mesma forma dos catálogos B80–B90.
## Fases verificáveis
1. **Tracer / schema+server** — constante de path + módulo `supporterQuickActions.ts` + entrada no registry; testes unit novos (`supporterQuickActions.unit.spec.ts`) + atualização do pin "unregistered paths" e teste de delegação no `campaignQuickAction.unit.spec.ts`.
2. **UI** — nenhuma mudança de componente; verificação via unit (catálogo por role/path) + smoke manual do FAB em `/campanha/apoiadores` se o dev server estiver disponível.
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`; PR Ready `--base main` + `Closes #22` + auto-merge.
## Rabbit holes / Não escopo (engenharia)
- **Context sync / prefill de município na ficha de apoiador** (`CampaignQuickActionContextSync`): o apoiador tem `municipality` opcional, mas a intenção não pede A1–A5 — fora de escopo; gatilho se a ficha ganhar wizards.
- **Tocar os botões da página `/apoiadores`** ("Novo" / "Importar CSV"): o botão da lista já cobre desktop; o drawer cobre mobile — não duplicar nem refatorar (o catálogo é independente).
- **B79 drawer antigo / `CampaignQuickActionsDrawer`**: arquitetura atual é FAB+overlay (B100+); nenhum resquício a alterar.
- **`needs:consent` na Issue:** herdado da vertical C2 (bloqueio de produção para dados reais); B86 só navega para flows existentes fail-closed — não coleta nada, não depende de chave nova.
## Riscos e mitigação
- **Gate de import divergir entre drawer e página** (advisor/candidate vendo "Importar CSV"): mitigado — gate `role === 'coordinator'` espelha `isCampaignCoordinator`, pinado em teste.
- **Ficha de apoiador com id não-numérico futuro** (slug): regex numérica só casa ids atuais; se virar slug, o parser evolui com a rota (mesmo caminho do assessor).
- **Colisão de path no registry:** `/campanha/apoiadores/*` não é reivindicado por nenhum matcher existente; teste de delegação pinado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (staff cadastra/importa do drawer; leader fora; sem A1–A5; busca já no overlay)
- [ ] Invariantes AGENTS/engineering-standards (client-safe, sem escrita, sem migration/Consent)
- [ ] Testes de domínio previstos (unit: paths, catálogo por role — coordinator 2 ações, advisor/candidate 1, leader `[]`; registry: delegação + unregistered paths atualizado)

View file

@ -0,0 +1,75 @@
# Escala/DRY pós-B86 — normalização de pathname de lista nas ações rápidas
Status: rascunho
Atualizado em: 2026-08-03
Issue: —
Prioridade: P2
Item do roadmap: débito do lote B80–B90 (colhido no `/simplify` de B86, triage aprovada)
Model: composer-2.5
Impeccable: A — refactor só-backend (client-safe), sem mudança de comportamento
Appetite: ~0,25–0,5 dia eng; sem migration
## Problema
O conhecimento de "normalizar pathname de lista (match exato + trailing slash)" está
duplicado em **6+ módulos** da família de ações rápidas:
- `isAdvisorsListPath` (`src/lib/campaignAdvisorQuickActions.ts`)
- `isDemandsListPath` (`src/lib/campaignQuickActionDemands.ts`)
- `isStateDeputyListPath` (`src/lib/campaignQuickActionDobradinhas.ts`)
- `isConceptsPath` / `isProfilePath` (`src/lib/campaignReferenceQuickActions.ts`)
- `isLeadershipListPath` (`src/lib/campaignQuickActionLeadership.ts`)
- `isSupportersListPath` (`src/lib/supporterQuickActions.ts` — B86)
E `src/lib/campaignQuickActionPaths.ts` já existe como home da família de path helpers
client-safe, com um `normalizePathname` **privado** fazendo exatamente o mesmo trabalho
(só usado pelos parsers de superfície activity/organization).
## Escopo
- Extrair helper compartilhado (ex. `isListPath(pathname, home)` e/ou
`normalizePathname` exportado) em `src/lib/campaignQuickActionPaths.ts`.
- Migrar os 6+ call sites acima para o helper; manter exports públicos de cada módulo
(`isXListPath`) para não quebrar specs/registry — os wrappers delegam.
- Se natural, derivar as regex de detalhe numérico da constante de home
(`escapeRegExp`) — só se o diff ficar limpo; caso contrário, registrar como fase 2
com gatilho (3º vertical com `[id]` numérico — hoje são 2: advisor + supporter).
- **Não** mexer nos parsers de superfície activity/organization além de reusar o
`normalizePathname` exportado (mesma lógica, menos duplicação).
## Já resolvido no simplify (não reabrir)
- Pins de teste do B86 (trailing-slash no registry, `/abc` → `[]`, mount staff em
`/apoiadores` e não-leader) — aplicados na sessão B86.
## Explicitamente fora (deste lote)
- **S2** naming polarity `import-supporters` × `SUPPORTER_IMPORT_HREF` — descartado
(batch plural intencional; score 2).
- **S3** regex aceita `042` — descartado (espelha `parseAdvisorDetailId`; consistência).
- **S4** caso advisor direto no spec do módulo — descartado (coberto via registry).
- **S5** quirks de regex (nested tail, dígitos ilimitados, id≤0) — descartados
(espelham siblings).
- **S6** extrair regex numérica em 2 sites — **defer com gatilho**: 3º vertical com
`[id]` numérico.
- **S7** cache `Map<CampaignRole>` em 3 sites — **defer com gatilho**: 4º site com
shape compatível (advisor/supporter/home divergem em gate e construção).
- **S8** id `register-supporter` sobrecarregado (leader × staff) — **defer com
gatilho**: se algo agregar catálogos ou assert de unicidade global de id.
## Fases verificáveis
1. Helper compartilhado em `campaignQuickActionPaths.ts` + migração dos 6+ call sites.
2. `pnpm gate:fast` (unit cobre todos os módulos tocados) + `pnpm push`.
## Rabbit holes
- Renomear `isXListPath` nos specs — os wrappers mantêm o contrato, specs intactos.
- Tocar comportamento (ex. normalizar pathname em vez de comparar os dois spelling) —
refactor é puramente estrutural; comportamento idêntico.
## Riscos e mitigação
- 6+ call sites = risco de diff ruidoso — wrappers delegando mantêm o diff mínimo.
- Algum matcher depende da ordem exata dos `===` — unit specs existentes pinam cada
módulo; rodar `test:unit` completo no gate.

View file

@ -7,6 +7,8 @@ export const CAMPAIGN_DEMANDS_HOME = '/campanha/demandas' as const
export const CAMPAIGN_ADVISORS_HOME = '/campanha/assessores' as const
export const CAMPAIGN_SUPPORTERS_HOME = '/campanha/apoiadores' as const
export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const
export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const

View file

@ -29,6 +29,7 @@ import {
import type { CampaignRole } from '@/lib/campaignRoles'
import { resolveLeaderContactsQuickActions } from '@/lib/leaderContactsQuickActions'
import { resolveOrganizationQuickActions } from '@/lib/organizationQuickActions'
import { resolveSupporterQuickActionsForPath } from '@/lib/supporterQuickActions'
const isTerritoriesListPath = (pathname: string): boolean =>
pathname === CAMPAIGN_TERRITORIES_HOME || pathname.startsWith(`${CAMPAIGN_TERRITORIES_HOME}/`)
@ -82,6 +83,9 @@ export const resolveQuickActionsForPath = (
const advisorActions = resolveAdvisorQuickActionsForPath(pathname, role, context)
if (advisorActions.length > 0) return advisorActions
const supporterActions = resolveSupporterQuickActionsForPath(pathname, role, context)
if (supporterActions.length > 0) return supporterActions
if (isTerritoriesListPath(pathname)) {
return resolveStaffHomeQuickActions(role, pathname)
}

View file

@ -0,0 +1,79 @@
import { FileUp, UserPlus } from 'lucide-react'
import { CAMPAIGN_SUPPORTERS_HOME } from '@/lib/campaignPaths'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
export const SUPPORTER_CREATE_HREF = `${CAMPAIGN_SUPPORTERS_HOME}/novo` as const
export const SUPPORTER_IMPORT_HREF = `${CAMPAIGN_SUPPORTERS_HOME}/importar` as const
const supporterDetailPathPattern = /^\/campanha\/apoiadores\/(\d+)(?:\/|$)/
export const isSupportersListPath = (pathname: string): boolean =>
pathname === CAMPAIGN_SUPPORTERS_HOME || pathname === `${CAMPAIGN_SUPPORTERS_HOME}/`
export const parseSupporterDetailId = (pathname: string): number | undefined => {
const match = pathname.match(supporterDetailPathPattern)
if (!match) return undefined
const id = Number(match[1])
return Number.isInteger(id) && id > 0 ? id : undefined
}
export const isSupportersPath = (pathname: string): boolean =>
isSupportersListPath(pathname) || parseSupporterDetailId(pathname) !== undefined
const registerSupporterAction: CampaignQuickAction = {
id: 'register-supporter',
label: 'Cadastrar apoiador',
icon: UserPlus,
description: 'Registrar um apoiador no cadastro nominal',
href: SUPPORTER_CREATE_HREF,
}
const importSupportersAction: CampaignQuickAction = {
id: 'import-supporters',
label: 'Importar CSV',
icon: FileUp,
description: 'Importar apoiadores em lote a partir de planilha',
href: SUPPORTER_IMPORT_HREF,
}
const coordinatorSupporterQuickActions: readonly CampaignQuickAction[] = [
registerSupporterAction,
importSupportersAction,
]
const staffSupporterQuickActions: readonly CampaignQuickAction[] = [registerSupporterAction]
const supporterQuickActionsByRole = new Map<CampaignRole, readonly CampaignQuickAction[]>()
const supporterQuickActionsForRole = (role: CampaignRole): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) return []
const cached = supporterQuickActionsByRole.get(role)
if (cached) return cached
// Import is coordinator-only — mirrors the page gate (`isCampaignCoordinator`);
// the import route redirects non-coordinators back to the list.
const actions =
role === 'coordinator' ? coordinatorSupporterQuickActions : staffSupporterQuickActions
supporterQuickActionsByRole.set(role, actions)
return actions
}
export const resolveSupporterListQuickActions = (
role: CampaignRole,
): readonly CampaignQuickAction[] => supporterQuickActionsForRole(role)
export const resolveSupporterDetailQuickActions = (
role: CampaignRole,
): readonly CampaignQuickAction[] => supporterQuickActionsForRole(role)
export const resolveSupporterQuickActionsForPath = (
pathname: string,
role: CampaignRole,
_context: CampaignQuickActionContext,
): readonly CampaignQuickAction[] => {
if (!isSupportersPath(pathname)) return []
return supporterQuickActionsForRole(role)
}

View file

@ -37,6 +37,7 @@ import {
} from '@/lib/campaignQuickActionPaths'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import { resolveOrganizationQuickActions } from '@/lib/organizationQuickActions'
import { SUPPORTER_CREATE_HREF, SUPPORTER_IMPORT_HREF } from '@/lib/supporterQuickActions'
describe('campaignQuickActionMount', () => {
it('treats Início as exact match only', () => {
@ -62,6 +63,8 @@ describe('campaignQuickActionMount', () => {
expect(shouldMountQuickActionsFab('/campanha/territorios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas/foo', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/apoiadores/42', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-sinal', 'advisor')).toBe(false)
})
@ -69,6 +72,7 @@ describe('campaignQuickActionMount', () => {
it('mounts for leader only on contacts', () => {
expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/municipios', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'leader')).toBe(false)
})
it('skips the E13 tour composer (B84)', () => {
@ -257,7 +261,7 @@ describe('campaignQuickActionRegistry', () => {
})
it('returns empty catalog for unregistered paths', () => {
expect(resolveQuickActionsForPath('/campanha/apoiadores', 'coordinator', {})).toEqual([])
expect(resolveQuickActionsForPath('/campanha/lugar-inexistente', 'coordinator', {})).toEqual([])
})
it('returns empty catalog on conceitos and perfil for staff (B90)', () => {
@ -368,6 +372,31 @@ describe('campaignQuickActionRegistry', () => {
it('returns empty catalog on assessores for advisor lockdown', () => {
expect(resolveQuickActionsForPath('/campanha/assessores', 'advisor', {})).toEqual([])
})
it('delegates apoiadores list and detail catalogs (B86)', () => {
const list = resolveQuickActionsForPath('/campanha/apoiadores', 'coordinator', {})
expect(list.map((action) => action.id)).toEqual(['register-supporter', 'import-supporters'])
expect(list[0]?.href).toBe(SUPPORTER_CREATE_HREF)
expect(list[1]?.href).toBe(SUPPORTER_IMPORT_HREF)
const advisorList = resolveQuickActionsForPath('/campanha/apoiadores', 'advisor', {})
expect(advisorList.map((action) => action.id)).toEqual(['register-supporter'])
const detail = resolveQuickActionsForPath('/campanha/apoiadores/9', 'candidate', {})
expect(detail.map((action) => action.id)).toEqual(['register-supporter'])
const listTrailingSlash = resolveQuickActionsForPath('/campanha/apoiadores/', 'advisor', {})
expect(listTrailingSlash.map((action) => action.id)).toEqual(['register-supporter'])
const detailTrailingSlash = resolveQuickActionsForPath(
'/campanha/apoiadores/9/',
'candidate',
{},
)
expect(detailTrailingSlash.map((action) => action.id)).toEqual(['register-supporter'])
expect(resolveQuickActionsForPath('/campanha/apoiadores', 'leader', {})).toEqual([])
})
})
describe('campaignQuickActionDemands paths', () => {

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
SUPPORTER_CREATE_HREF,
SUPPORTER_IMPORT_HREF,
isSupportersListPath,
isSupportersPath,
parseSupporterDetailId,
resolveSupporterDetailQuickActions,
resolveSupporterListQuickActions,
resolveSupporterQuickActionsForPath,
} from '@/lib/supporterQuickActions'
describe('supporterQuickActions paths', () => {
it('matches apoiadores list exactly', () => {
expect(isSupportersListPath('/campanha/apoiadores')).toBe(true)
expect(isSupportersListPath('/campanha/apoiadores/')).toBe(true)
expect(isSupportersListPath('/campanha/apoiadores/novo')).toBe(false)
expect(isSupportersListPath('/campanha/apoiadores/importar')).toBe(false)
})
it('matches apoiadores detail ids', () => {
expect(parseSupporterDetailId('/campanha/apoiadores/42')).toBe(42)
expect(parseSupporterDetailId('/campanha/apoiadores/42/')).toBe(42)
expect(parseSupporterDetailId('/campanha/apoiadores/novo')).toBeUndefined()
expect(parseSupporterDetailId('/campanha/apoiadores/importar')).toBeUndefined()
expect(parseSupporterDetailId('/campanha/apoiadores/abc')).toBeUndefined()
expect(isSupportersPath('/campanha/apoiadores/42')).toBe(true)
expect(isSupportersPath('/campanha/apoiadores/novo')).toBe(false)
})
})
describe('resolveSupporterListQuickActions', () => {
it('returns register-supporter + import-supporters for coordinator', () => {
const actions = resolveSupporterListQuickActions('coordinator')
expect(actions.map((action) => action.id)).toEqual(['register-supporter', 'import-supporters'])
expect(actions[0]?.href).toBe(SUPPORTER_CREATE_HREF)
expect(actions[1]?.href).toBe(SUPPORTER_IMPORT_HREF)
})
it('returns register-supporter only for advisor and candidate', () => {
for (const role of ['advisor', 'candidate'] as const) {
const actions = resolveSupporterListQuickActions(role)
expect(actions.map((action) => action.id)).toEqual(['register-supporter'])
expect(actions[0]?.href).toBe(SUPPORTER_CREATE_HREF)
}
})
it('returns empty catalog for leader lockdown', () => {
expect(resolveSupporterListQuickActions('leader')).toEqual([])
})
})
describe('resolveSupporterDetailQuickActions', () => {
it('mirrors the list catalog on detail (B86)', () => {
expect(resolveSupporterDetailQuickActions('coordinator').map((action) => action.id)).toEqual([
'register-supporter',
'import-supporters',
])
expect(resolveSupporterDetailQuickActions('advisor').map((action) => action.id)).toEqual([
'register-supporter',
])
expect(resolveSupporterDetailQuickActions('leader')).toEqual([])
})
})
describe('resolveSupporterQuickActionsForPath', () => {
it('delegates list and detail catalogs (B86)', () => {
const list = resolveSupporterQuickActionsForPath('/campanha/apoiadores', 'coordinator', {})
expect(list.map((action) => action.id)).toEqual(['register-supporter', 'import-supporters'])
const detail = resolveSupporterQuickActionsForPath('/campanha/apoiadores/7', 'coordinator', {})
expect(detail.map((action) => action.id)).toEqual(['register-supporter', 'import-supporters'])
})
it('returns empty catalog on form and wizard pages (search-only FAB)', () => {
expect(
resolveSupporterQuickActionsForPath('/campanha/apoiadores/novo', 'coordinator', {}),
).toEqual([])
expect(
resolveSupporterQuickActionsForPath('/campanha/apoiadores/importar', 'coordinator', {}),
).toEqual([])
})
it('returns empty catalog on non-numeric detail (route 404s)', () => {
expect(
resolveSupporterQuickActionsForPath('/campanha/apoiadores/abc', 'coordinator', {}),
).toEqual([])
})
it('returns empty catalog for leader lockdown', () => {
expect(resolveSupporterQuickActionsForPath('/campanha/apoiadores', 'leader', {})).toEqual([])
})
})