mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
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:
parent
2414001606
commit
30a5a8a5e3
6 changed files with 289 additions and 0 deletions
83
docs/plans/municipio-v2-fab-secundario-impl.md
Normal file
83
docs/plans/municipio-v2-fab-secundario-impl.md
Normal 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
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
83
src/lib/campaignMunicipalityV2QuickActions.ts
Normal file
83
src/lib/campaignMunicipalityV2QuickActions.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
105
tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts
Normal file
105
tests/unit/campaignMunicipalityV2QuickActions.unit.spec.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue