B151 — FAB secundário no detalhe município v2 (#334)

- Catálogo v2 de ações rápidas: dossiê, eleições, nova liderança, giro
- Integra no chassis B126 via registry + context sync na rota v2
- Omitido registrar sinal (já na faixa de status B147)

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-03 00:09:35 +00:00
parent 2414001606
commit 30a5a8a5e3
No known key found for this signature in database
6 changed files with 289 additions and 0 deletions

View file

@ -0,0 +1,83 @@
# Impl: Município v2 — ações secundárias no FAB
Status: aprovado
Atualizado em: 2026-08-03
Issue: #334
Intenção: docs/plans/municipio-v2-fab-secundario.md
Appetite restante: ~0,5 dia eng (herdado)
## Leitura da intenção
- **Outcome:** na rota v2, secundárias (dossiê, eleições, nova liderança, compositor de giros) alcançáveis via FAB B126, sem tab nav de 6 itens; papel respeitado; dobra de operação intacta; print/dossiê sem FAB.
- **O que NÃO negociar:** leader lockdown; não replicar wizard de operação no FAB v2; não omitir `print:hidden` do chassis; não duplicar segundo FAB; registrar sinal omitido (assunção A — select na faixa B147).
- **O que reavaliar:** hipótese de “integrar no chassis global” — confirmado: estender `resolveQuickActionsForPath` com superfície v2, não componente paralelo.
## Abordagem recomendada
```mermaid
flowchart LR
V2Page["municipio/[slug]/v2/page.tsx"]
Sync["CampaignQuickActionContextSync"]
Registry["resolveQuickActionsForPath"]
V2QA["resolveMunicipalityV2QuickActions"]
Host["CampaignQuickActionsHost"]
V2Page --> Sync
Host --> Registry --> V2QA
Sync --> Host
```
**Opções consideradas:**
| | A — Reusar `resolveMunicipalityDetailQuickActions` (wizard) | B — Catálogo v2 dedicado de navegação secundária | C — Segundo FAB só na v2 |
| --- | ----------------------------------------------------------- | ------------------------------------------------ | ------------------------ |
| | Já existe | Alinha com aceite (secundárias ≠ operação) | Viola anti-goal |
**Recomendação:** B — because v2 operational edits live in the status strip / future dobra; the old detail FAB ships wizard actions that would compete with B147–B150. A dedicated client-safe resolver returns navigation hrefs to existing surfaces.
**Rejeitadas:** A (wizard noise on v2). C (twin FAB).
### Componentes / mudanças
- **`campaignMunicipalityV2QuickActions.ts`** (`src/lib/`): `parseMunicipalityV2Slug`, `resolveMunicipalityV2QuickActions`, `resolveMunicipalityV2QuickActionsForPath`; links via `buildMunicipalityDetailTabHref`, `buildTourComposerHref` + catalog `region`, `/campanha/liderancas/nova?municipality=`.
- **`campaignQuickActionRegistry.ts`:** early branch for v2 path before plural `municipios` resolver.
- **`municipio/[slug]/v2/page.tsx`:** `CampaignQuickActionContextSync` with `municipalitySlug` + `municipalityId` from loader context.
- **Migration:** nenhuma.
- **Access / Consent:** staff-only via page gate `noLeader`; same as v2 shell.
- **UI:** Impeccable B — reuse B126 host; no new chrome. Print: existing `print:hidden` on FAB suffices when user opens dossier on old tab route.
### Catálogo FAB v2 (staff)
| id | label | destino |
| ------------------------ | --------------- | --------------------------------------------- |
| `municipality-dossier` | Preparar visita | `/campanha/municipios/{slug}?tab=dossie` |
| `municipality-elections` | Ver eleições | `/campanha/municipios/{slug}?tab=elections` |
| `new-leadership` | Nova liderança | `/campanha/liderancas/nova?municipality={id}` |
| `plan-tour` | Planejar giro | `/campanha/atividades/giros?region={TI}` |
Omitido: `register-signal` (assunção A).
## Fases verificáveis
1. **Lib + registry** — resolver + unit tests.
2. **Page wire** — context sync on v2 RSC.
3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Cutover URLs (B152).
- Reescrever dossiê/eleições.
- Atualizações/demandas no FAB (rede/agora nas fatias B149–B150).
- `returnPath` de volta à v2 nos links do detalhe antigo.
## Riscos e mitigação
- **Slug singular vs plural:** parser dedicado `^/campanha/municipio/([^/]+)/v2$` — não colide com `municipios`.
- **Giros sem TI:** `buildTourComposerHref({ region: null })` se catalog miss (defensivo).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes unitários do resolver v2
Self-score decision-quality: 4/5

View file

@ -32,6 +32,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/map',
'src/components/campaign/shared/CampaignListOmnibox',
'src/lib/campaignListOmnibox',
'src/lib/campaignMunicipality',
'src/utilities/municipality',
],
specs: [

View file

@ -8,6 +8,7 @@ import { MunicipalityV2AgoraSection } from '@/components/campaign/municipality/M
import { MunicipalityV2NetworkSection } from '@/components/campaign/municipality/MunicipalityV2NetworkSection'
import { MunicipalityV2StatusStrip } from '@/components/campaign/municipality/MunicipalityV2StatusStrip'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -35,6 +36,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
])
let status
<<<<<<< HEAD
let network
let agora
try {
@ -43,9 +45,19 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
loadMunicipalityV2NetworkData(payload, user, slug),
loadMunicipalityV2AgoraData(payload, user, slug),
])
<<<<<<< HEAD
;({ status } = statusLoaded)
network = networkLoaded
agora = agoraLoaded
=======
;({ status } = loaded[0])
network = loaded[1]
=======
let context
try {
;({ status, context } = await loadMunicipalityV2StatusData(payload, user, slug))
>>>>>>> b1786d18 (B151 — FAB secundário no detalhe município v2 (#334))
>>>>>>> f198d718 (B151 — FAB secundário no detalhe município v2 (#334))
} catch (error) {
if (error instanceof MunicipalityNotFoundError) notFound()
throw error
@ -53,6 +65,7 @@ export default async function MunicipalityV2Page({ params }: MunicipalityV2PageP
return (
<CampaignPageShell>
<CampaignQuickActionContextSync municipalitySlug={context.slug} municipalityId={context.id} />
<div className="flex flex-col gap-8">
<MunicipalityV2StatusStrip
status={status}

View file

@ -0,0 +1,83 @@
import { BarChart3, FileText, MapPinned, UserPlus } from 'lucide-react'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
const municipalityV2PathPattern = /^\/campanha\/municipio\/([^/]+)\/v2$/
const municipalityDetailTabHref = (slug: string, tab: 'dossie' | 'elections'): string =>
`/campanha/municipios/${slug}?tab=${tab}`
export const isMunicipalityV2Path = (pathname: string): boolean =>
municipalityV2PathPattern.test(pathname)
export const parseMunicipalityV2Slug = (pathname: string): string | undefined => {
const match = pathname.match(municipalityV2PathPattern)
return match?.[1]
}
export const resolveMunicipalityV2QuickActions = (
role: CampaignRole,
municipalitySlug: string,
municipalityId?: number,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) return []
const catalogEntry = getMunicipalityCatalogEntry(municipalitySlug)
const tourHref = catalogEntry?.region
? `${ACTIVITY_TOUR_COMPOSER_PATH}?region=${encodeURIComponent(catalogEntry.region)}`
: ACTIVITY_TOUR_COMPOSER_PATH
const actions: CampaignQuickAction[] = [
{
id: 'municipality-dossier',
label: 'Preparar visita',
icon: FileText,
description: 'Abrir o dossiê de pré-visita deste município',
href: municipalityDetailTabHref(municipalitySlug, 'dossie'),
},
{
id: 'municipality-elections',
label: 'Ver eleições',
icon: BarChart3,
description: 'Comparativo e linha de base TSE deste município',
href: municipalityDetailTabHref(municipalitySlug, 'elections'),
},
]
if (municipalityId !== undefined) {
actions.push({
id: 'new-leadership',
label: 'Nova liderança',
icon: UserPlus,
description: 'Cadastrar uma liderança vinculada a este município',
href: `/campanha/liderancas/nova?municipality=${municipalityId}`,
})
}
actions.push({
id: 'plan-tour',
label: 'Planejar giro',
icon: MapPinned,
description: catalogEntry
? `Abrir o compositor de giros em ${catalogEntry.region}`
: 'Abrir o compositor de giros por território',
href: tourHref,
})
return actions
}
export const resolveMunicipalityV2QuickActionsForPath = (
pathname: string,
role: CampaignRole,
context: CampaignQuickActionContext,
): readonly CampaignQuickAction[] | null => {
const slug = context.municipalitySlug ?? parseMunicipalityV2Slug(pathname)
if (!slug || !isMunicipalityV2Path(pathname)) return null
return resolveMunicipalityV2QuickActions(role, slug, context.municipalityId)
}

View file

@ -2,6 +2,7 @@ import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import { resolveAdvisorQuickActionsForPath } from '@/lib/campaignAdvisorQuickActions'
import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions'
import { resolveMunicipalityQuickActionsForPath } from '@/lib/campaignMunicipalityQuickActions'
import { resolveMunicipalityV2QuickActionsForPath } from '@/lib/campaignMunicipalityV2QuickActions'
import { CAMPAIGN_TERRITORIES_HOME } from '@/lib/campaignPaths'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import {
@ -63,6 +64,9 @@ export const resolveQuickActionsForPath = (
return resolveDemandDetailQuickActions(role, context, pathname)
}
const municipalityV2Actions = resolveMunicipalityV2QuickActionsForPath(pathname, role, context)
if (municipalityV2Actions !== null) return municipalityV2Actions
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
if (municipalityActions.length > 0) return municipalityActions

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
isMunicipalityV2Path,
parseMunicipalityV2Slug,
resolveMunicipalityV2QuickActions,
resolveMunicipalityV2QuickActionsForPath,
} from '@/lib/campaignMunicipalityV2QuickActions'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
describe('campaignMunicipalityV2QuickActions paths', () => {
it('detects the municipality v2 path', () => {
expect(isMunicipalityV2Path('/campanha/municipio/cairu/v2')).toBe(true)
expect(isMunicipalityV2Path('/campanha/municipios/cairu')).toBe(false)
expect(isMunicipalityV2Path('/campanha/municipio/cairu')).toBe(false)
})
it('parses municipality v2 slugs', () => {
expect(parseMunicipalityV2Slug('/campanha/municipio/cairu/v2')).toBe('cairu')
expect(parseMunicipalityV2Slug('/campanha/municipio/feira-de-santana/v2')).toBe(
'feira-de-santana',
)
expect(parseMunicipalityV2Slug('/campanha/municipios/cairu')).toBeUndefined()
})
})
describe('resolveMunicipalityV2QuickActions', () => {
it('lists secondary navigation for staff with municipality prefill', () => {
const actions = resolveMunicipalityV2QuickActions('coordinator', 'cairu', 42)
expect(actions.map((action) => action.id)).toEqual([
'municipality-dossier',
'municipality-elections',
'new-leadership',
'plan-tour',
])
expect(actions.find((action) => action.id === 'municipality-dossier')?.href).toBe(
'/campanha/municipios/cairu?tab=dossie',
)
expect(actions.find((action) => action.id === 'municipality-elections')?.href).toBe(
'/campanha/municipios/cairu?tab=elections',
)
expect(actions.find((action) => action.id === 'new-leadership')?.href).toBe(
'/campanha/liderancas/nova?municipality=42',
)
expect(actions.find((action) => action.id === 'plan-tour')?.href).toContain(
'/campanha/atividades/giros?region=',
)
})
it('omits register-signal and wizard operational actions', () => {
const actions = resolveMunicipalityV2QuickActions('advisor', 'cairu', 1)
expect(actions.some((action) => action.id === 'register-signal')).toBe(false)
expect(actions.some((action) => action.id === 'update-votes')).toBe(false)
})
it('returns nothing for leader', () => {
expect(resolveMunicipalityV2QuickActions('leader', 'cairu', 1)).toEqual([])
})
it('omits new-leadership without municipality id', () => {
const actions = resolveMunicipalityV2QuickActions('coordinator', 'cairu')
expect(actions.map((action) => action.id)).toEqual([
'municipality-dossier',
'municipality-elections',
'plan-tour',
])
})
})
describe('resolveMunicipalityV2QuickActionsForPath', () => {
it('returns null outside v2', () => {
expect(
resolveMunicipalityV2QuickActionsForPath('/campanha/municipios/cairu', 'coordinator', {}),
).toBeNull()
})
it('prefers context slug over pathname', () => {
const actions = resolveMunicipalityV2QuickActionsForPath(
'/campanha/municipio/feira-de-santana/v2',
'coordinator',
{ municipalitySlug: 'cairu', municipalityId: 7 },
)
expect(actions?.find((action) => action.id === 'municipality-dossier')?.href).toContain(
'/campanha/municipios/cairu',
)
expect(actions?.find((action) => action.id === 'new-leadership')?.href).toContain(
'municipality=7',
)
})
})
describe('campaignQuickActionRegistry municipality v2', () => {
it('returns v2 secondary actions instead of wizard catalog', () => {
const actions = resolveQuickActionsForPath('/campanha/municipio/cairu/v2', 'coordinator', {
municipalitySlug: 'cairu',
municipalityId: 99,
})
expect(actions.map((action) => action.id)).toEqual([
'municipality-dossier',
'municipality-elections',
'new-leadership',
'plan-tour',
])
})
})