From 30a5a8a5e3208fc90cf55d25dc335ca4b388c8d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:09:35 +0000 Subject: [PATCH] =?UTF-8?q?B151=20=E2=80=94=20FAB=20secund=C3=A1rio=20no?= =?UTF-8?q?=20detalhe=20munic=C3=ADpio=20v2=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../plans/municipio-v2-fab-secundario-impl.md | 83 ++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 1 + .../(app)/municipio/[slug]/v2/page.tsx | 13 +++ src/lib/campaignMunicipalityV2QuickActions.ts | 83 ++++++++++++++ src/lib/campaignQuickActionRegistry.ts | 4 + ...ignMunicipalityV2QuickActions.unit.spec.ts | 105 ++++++++++++++++++ 6 files changed, 289 insertions(+) create mode 100644 docs/plans/municipio-v2-fab-secundario-impl.md create mode 100644 src/lib/campaignMunicipalityV2QuickActions.ts create mode 100644 tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts diff --git a/docs/plans/municipio-v2-fab-secundario-impl.md b/docs/plans/municipio-v2-fab-secundario-impl.md new file mode 100644 index 00000000..1c6ae3ad --- /dev/null +++ b/docs/plans/municipio-v2-fab-secundario-impl.md @@ -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 diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 4ad88700..2f3f8ac6 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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: [ diff --git a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx index 17bbf441..c70cbf04 100644 --- a/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/page.tsx @@ -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 ( +
+ `/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) +} diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts index 695b475f..6caa1a41 100644 --- a/src/lib/campaignQuickActionRegistry.ts +++ b/src/lib/campaignQuickActionRegistry.ts @@ -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 diff --git a/tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts b/tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts new file mode 100644 index 00000000..07e9e625 --- /dev/null +++ b/tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts @@ -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', + ]) + }) +})