mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
7261f3f588
commit
d5da7b0adf
10 changed files with 247 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
100
src/lib/campaignQuickActionLeadership.ts
Normal file
100
src/lib/campaignQuickActionLeadership.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
|
|
|
|||
53
tests/unit/campaignQuickActionLeadership.unit.spec.ts
Normal file
53
tests/unit/campaignQuickActionLeadership.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue