B82 — Ações rápidas em lideranças (lista + detalhe)

Registra catálogo contextual no drawer B79 para /campanha/liderancas:
lista com update-leadership; detalhe com cinco wizards e prefill de
município quando N=1. Adiciona query leadershipId no wizard A4 e
CampaignQuickActionContextSync na ficha.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 04:08:04 +00:00
parent 7261f3f588
commit d5da7b0adf
No known key found for this signature in database
10 changed files with 247 additions and 12 deletions

View file

@ -1,7 +1,7 @@
# Ações rápidas — Lideranças (lista + detalhe)
Status: rascunho
Atualizado em: 2026-07-30
Status: entregue (B82 — 2026-08-01)
Atualizado em: 2026-08-01
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B82**)
Impeccable: B — catálogo drawer B79 em `/liderancas` e `/liderancas/[id]`
Appetite: ~0,5 dia eng; prefills A4 + município(s) da liderança; sem migration
@ -38,7 +38,15 @@ Se B70 ainda não expuser query de liderança, o agente deve (1) propor o param
## Questões em aberto
- **Query `?lideranca=` no wizard A4?** **Opções:** A) sim no B70/B82 | B) só município. **Recomendação:** **A** quando B70 estiver no ar — senão degradar.
- **Query `?lideranca=` no wizard A4?** **Resolvido (as-built):** param **`leadershipId`** (inglês) em `wizardActionHref` + `WizardLeadershipStep` abre o form quando o id bate com um tile do município; sem município único, `update-leadership` degrada para `/campanha/liderancas/[id]`.
## As-built (2026-08-01)
- Registry: `src/lib/campaignQuickActionLeadership.ts` — lista = só `update-leadership`; detalhe = cinco wizards com prefill de município quando N=1.
- Contexto: `CampaignQuickActionContextSync` na ficha `[id]` (slug único via portfolio index).
- Wizard: `WIZARD_LEADERSHIP_ID_QUERY_KEY` + auto-abertura do form no B70.
## Questões em aberto (histórico)
## Abordagem proposta

View file

@ -13,6 +13,7 @@ import {
hasWizardScenarioParam,
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
@ -126,6 +127,9 @@ export default async function CampaignActionWizardPage({
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const initialLeadershipId = parseWizardLeadershipIdParam(
resolvedSearchParams.leadershipId,
)
const tiles = await loadWizardLeadershipTiles(payload, user, municipality.id)
return (
@ -136,6 +140,7 @@ export default async function CampaignActionWizardPage({
municipalitySlug={municipality.slug}
entryAction={entryAction}
initialTiles={tiles}
initialLeadershipId={initialLeadershipId}
/>
)
}

View file

@ -8,9 +8,11 @@ import { LeadershipInviteButtons } from '@/components/campaign/invite/Leadership
import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
@ -18,6 +20,7 @@ import {
loadStateDeputyOptions,
} from '@/utilities/campaignRelationOptions'
import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { updateLeadershipInternalFormAction } from './formActions'
type LeadershipDetailPageProps = {
@ -36,14 +39,27 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
const leadership = await loadLeadershipDetail(payload, user, Number(id))
if (!leadership) notFound()
const [municipalityOptions, organizationOptions, stateDeputyOptions] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadStateDeputyOptions(payload, user),
])
const [municipalityOptions, organizationOptions, stateDeputyOptions, municipalityIndex] =
await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadStateDeputyOptions(payload, user),
loadMunicipalityPortfolioIndex(),
])
const municipalityById = resolvedPortfolioEntriesById(municipalityIndex)
const municipalitySlugs = leadership.municipalityIDs
.map((id) => municipalityById.get(id)?.slug)
.filter((slug): slug is string => slug !== undefined)
const singleMunicipalitySlug =
municipalitySlugs.length === 1 ? municipalitySlugs[0] : undefined
return (
<CampaignPageShell>
<CampaignQuickActionContextSync
leadershipId={leadership.id}
municipalitySlug={singleMunicipalitySlug}
/>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">

View file

@ -2,7 +2,7 @@
import { InfoIcon, PlusIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useCallback, useState, useTransition } from 'react'
import { useCallback, useMemo, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
@ -48,6 +48,7 @@ type WizardLeadershipStepProps = {
municipalitySlug: string
entryAction?: CampaignWizardActionId
initialTiles: WizardLeadershipTileViewModel[]
initialLeadershipId?: number
}
type WizardLeadershipMode =
@ -67,10 +68,18 @@ export const WizardLeadershipStep = ({
municipalitySlug,
entryAction,
initialTiles,
initialLeadershipId,
}: WizardLeadershipStepProps) => {
const router = useRouter()
const [isContinuing, startContinueTransition] = useTransition()
const [mode, setMode] = useState<WizardLeadershipMode>({ kind: 'grid' })
const initialMode = useMemo((): WizardLeadershipMode => {
if (initialLeadershipId === undefined) {
return { kind: 'grid' }
}
const leadership = initialTiles.find((tile) => tile.id === initialLeadershipId)
return leadership ? { kind: 'form', leadership } : { kind: 'grid' }
}, [initialLeadershipId, initialTiles])
const [mode, setMode] = useState<WizardLeadershipMode>(initialMode)
const [dirty, setDirty] = useState(false)
const [infoTile, setInfoTile] = useState<WizardLeadershipTileViewModel | null>(null)

View file

@ -65,8 +65,11 @@ export const WIZARD_VOTE_FROM_QUERY_KEY = 'voteFrom' as const
export const WIZARD_VOTE_TO_QUERY_KEY = 'voteTo' as const
export const WIZARD_LEADERSHIP_ID_QUERY_KEY = 'leadershipId' as const
export type WizardActionHrefOptions = {
entryAction?: CampaignWizardActionId
leadershipId?: number
}
export const wizardActionHref = (
@ -75,13 +78,19 @@ export const wizardActionHref = (
options?: WizardActionHrefOptions,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
if (!municipalitySlug && !options?.leadershipId && !options?.entryAction) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
const params = new URLSearchParams()
if (municipalitySlug) {
params.set(WIZARD_MUNICIPIO_QUERY_KEY, municipalitySlug)
}
if (options?.entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, options.entryAction)
}
if (options?.leadershipId !== undefined) {
params.set(WIZARD_LEADERSHIP_ID_QUERY_KEY, String(options.leadershipId))
}
return `${base}?${params.toString()}`
}
@ -93,6 +102,17 @@ export const parseWizardMunicipioParam = (
return trimmed ? trimmed : undefined
}
export const parseWizardLeadershipIdParam = (
value: string | string[] | undefined,
): number | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed || !/^[1-9]\d*$/.test(trimmed)) {
return undefined
}
return Number(trimmed)
}
export const hasWizardScenarioParam = (value: string | string[] | undefined): boolean =>
parseWizardMunicipioParam(value) !== undefined

View file

@ -0,0 +1,100 @@
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
campaignActionEntryHref,
type CampaignWizardActionId,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
import {
homeActionsForRole,
type CampaignHomeAction,
type ResolvedCampaignHomeAction,
} from '@/lib/campaignHomeActions'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
const LEADERSHIP_LIST_PATH = '/campanha/liderancas' as const
const LEADERSHIP_DETAIL_WIZARD_IDS: readonly CampaignWizardActionId[] = [
'update-votes',
'register-signal',
'change-trend',
'update-leadership',
'register-demand',
]
const isLeadershipListPath = (pathname: string): boolean =>
pathname === LEADERSHIP_LIST_PATH || pathname === `${LEADERSHIP_LIST_PATH}/`
const parseLeadershipDetailId = (pathname: string): number | undefined => {
const match = pathname.match(/^\/campanha\/liderancas\/(\d+)$/)
if (!match?.[1]) return undefined
return Number(match[1])
}
const resolveLeadershipListActions = (role: CampaignRole): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) => action.id === 'update-leadership')
return actions.map((action) => ({
...action,
href: campaignActionEntryHref('update-leadership'),
}))
}
const hrefForLeadershipDetailAction = (
action: CampaignHomeAction,
context: CampaignQuickActionContext,
): string => {
const wizardSlug = CAMPAIGN_WIZARD_ACTION_SLUGS[action.id as CampaignWizardActionId]
const { municipalitySlug, leadershipId } = context
if (action.id === 'update-leadership') {
if (municipalitySlug && leadershipId !== undefined) {
return wizardActionHref(wizardSlug, municipalitySlug, { leadershipId })
}
if (leadershipId !== undefined) {
return `${LEADERSHIP_LIST_PATH}/${leadershipId}`
}
return campaignActionEntryHref('update-leadership')
}
if (municipalitySlug) {
return wizardActionHref(wizardSlug, municipalitySlug)
}
return campaignActionEntryHref(action.id as CampaignWizardActionId)
}
const resolveLeadershipDetailActions = (
role: CampaignRole,
context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => {
const actions = homeActionsForRole(role).filter((action) =>
LEADERSHIP_DETAIL_WIZARD_IDS.includes(action.id as CampaignWizardActionId),
)
return actions.map((action) => ({
...action,
href: hrefForLeadershipDetailAction(action, context),
}))
}
export const resolveLeadershipQuickActions = (
pathname: string,
role: CampaignRole,
context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] | null => {
if (!isStaffCampaignRole(role)) return null
if (isLeadershipListPath(pathname)) {
return resolveLeadershipListActions(role)
}
const leadershipId = parseLeadershipDetailId(pathname)
if (leadershipId !== undefined) {
return resolveLeadershipDetailActions(role, {
...context,
leadershipId: context.leadershipId ?? leadershipId,
})
}
return null
}

View file

@ -14,6 +14,7 @@ import {
matchesDobradinhasQuickActionSurface,
resolveDobradinhasQuickActions,
} from '@/lib/campaignQuickActionDobradinhas'
import { resolveLeadershipQuickActions } from '@/lib/campaignQuickActionLeadership'
import { isLeaderContactsPath } from '@/lib/campaignQuickActionMount'
import {
parseActivityQuickActionSurface,
@ -61,6 +62,11 @@ export const resolveQuickActionsForPath = (
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
if (municipalityActions.length > 0) return municipalityActions
const leadershipActions = resolveLeadershipQuickActions(pathname, role, context)
if (leadershipActions !== null) {
return leadershipActions
}
if (matchesDobradinhasQuickActionSurface(pathname)) {
return resolveDobradinhasQuickActions(pathname, role)
}

View file

@ -9,6 +9,7 @@ import {
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardLeadershipIdParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
resolveWizardTrendStatusParam,
@ -44,6 +45,17 @@ describe('campaignActionRoutes', () => {
expect(wizardActionHref('atualizar-lideranca', 'cairu', { entryAction: 'update-votes' })).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&entry=update-votes`,
)
expect(wizardActionHref('atualizar-lideranca', 'cairu', { leadershipId: 42 })).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`,
)
})
it('parses leadershipId search param', () => {
expect(parseWizardLeadershipIdParam('42')).toBe(42)
expect(parseWizardLeadershipIdParam(['99'])).toBe(99)
expect(parseWizardLeadershipIdParam(undefined)).toBeUndefined()
expect(parseWizardLeadershipIdParam('0')).toBeUndefined()
expect(parseWizardLeadershipIdParam('abc')).toBeUndefined()
})
it('detects legacy scenario search param', () => {

View file

@ -249,6 +249,12 @@ describe('campaignQuickActionRegistry', () => {
).toEqual([])
})
it('returns leadership list catalog on /liderancas (B82)', () => {
const actions = resolveQuickActionsForPath('/campanha/liderancas', 'coordinator', {})
expect(actions).toHaveLength(1)
expect(actions[0]?.id).toBe('update-leadership')
})
it('returns empty catalog for unregistered paths', () => {
expect(resolveQuickActionsForPath('/campanha/apoiadores', 'coordinator', {})).toEqual([])
})

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { resolveLeadershipQuickActions } from '@/lib/campaignQuickActionLeadership'
describe('resolveLeadershipQuickActions', () => {
it('returns only update-leadership on the list without prefill', () => {
const actions = resolveLeadershipQuickActions('/campanha/liderancas', 'coordinator', {})
expect(actions).toHaveLength(1)
expect(actions?.[0]?.id).toBe('update-leadership')
expect(actions?.[0]?.href).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca`)
})
it('returns five wizard actions on detail with municipality prefill when N=1', () => {
const actions = resolveLeadershipQuickActions('/campanha/liderancas/42', 'coordinator', {
leadershipId: 42,
municipalitySlug: 'cairu',
})
expect(actions?.map((action) => action.id)).toEqual([
'update-votes',
'register-signal',
'change-trend',
'update-leadership',
'register-demand',
])
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
expect(actions?.find((action) => action.id === 'update-leadership')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-lideranca?municipio=cairu&leadershipId=42`,
)
})
it('degrades update-leadership to ficha when N>1 (no municipality slug)', () => {
const actions = resolveLeadershipQuickActions('/campanha/liderancas/7', 'advisor', {
leadershipId: 7,
})
expect(actions?.find((action) => action.id === 'update-leadership')?.href).toBe(
'/campanha/liderancas/7',
)
expect(actions?.find((action) => action.id === 'update-votes')?.href).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`,
)
})
it('returns null outside leadership routes', () => {
expect(
resolveLeadershipQuickActions('/campanha/municipios', 'coordinator', {}),
).toBeNull()
expect(resolveLeadershipQuickActions('/campanha/liderancas/nova', 'coordinator', {})).toBeNull()
expect(resolveLeadershipQuickActions('/campanha/liderancas', 'leader', {})).toBeNull()
})
})