Merge pull request #76 from fsolla/cursor/pool-i20-8ddad200-1588

B84 — Ações rápidas — Atividades (lista + detalhe + giros)
This commit is contained in:
Francisco Solla 2026-08-01 02:06:32 -03:00 committed by GitHub
commit d7bef57933
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 345 additions and 9 deletions

View file

@ -44,6 +44,7 @@ export const E2E_AFFECTED_MANIFEST = [
`${CAMPAIGN_APP}/atividades`,
'src/components/campaign/activity',
'src/utilities/activity',
'src/lib/activityQuickActions',
],
specs: ['campaignActivity'],
},
@ -84,6 +85,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/shell',
`${CAMPAIGN_APP}/layout.tsx`,
'src/utilities/campaignPwa',
'src/lib/campaignQuickAction',
],
specs: ['campaign-pwa', 'campaignWizardChrome'],
},

View file

@ -13,6 +13,7 @@ import { ActivityTabNav } from '@/components/campaign/activity/ActivityTabNav'
import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTaskChecklist'
import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed'
import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm'
import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -65,6 +66,10 @@ export default async function ActivityDetailPage({
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<CampaignQuickActionContextBridge
activitySlug={view.slug}
municipalitySlug={view.municipality?.slug}
/>
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-2">

View file

@ -0,0 +1,46 @@
'use client'
import { useEffect, useMemo } from 'react'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import {
emptyCampaignQuickActionContext,
type CampaignQuickActionContext,
} from '@/lib/campaignQuickActionContext'
type CampaignQuickActionContextBridgeProps = {
municipalitySlug?: string
activitySlug?: string
leadershipId?: number
organizationSlug?: string
demandSlug?: string
}
/** Syncs page-local quick-action context into the B79 drawer host. */
export const CampaignQuickActionContextBridge = ({
municipalitySlug,
activitySlug,
leadershipId,
organizationSlug,
demandSlug,
}: CampaignQuickActionContextBridgeProps) => {
const { setContext } = useCampaignQuickActionContext()
const context = useMemo((): CampaignQuickActionContext => {
const next: CampaignQuickActionContext = {}
if (municipalitySlug) next.municipalitySlug = municipalitySlug
if (activitySlug) next.activitySlug = activitySlug
if (leadershipId !== undefined) next.leadershipId = leadershipId
if (organizationSlug) next.organizationSlug = organizationSlug
if (demandSlug) next.demandSlug = demandSlug
return next
}, [activitySlug, demandSlug, leadershipId, municipalitySlug, organizationSlug])
useEffect(() => {
setContext(context)
return () => {
setContext(emptyCampaignQuickActionContext())
}
}, [context, setContext])
return null
}

View file

@ -4,7 +4,6 @@ import { useCallback, useState } from 'react'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer'
import type { ResolvedCampaignHomeAction } from '@/lib/campaignHomeActions'
import {
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_EXPANDED,
@ -12,6 +11,7 @@ import {
quickActionsSnapIsExpanded,
type QuickActionsSnapPoint,
} from '@/lib/campaignQuickActionSnap'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import { cn } from '@/lib/utils'
const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS]
@ -19,7 +19,7 @@ const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS]
export const CampaignQuickActionsDrawer = ({
actions,
}: {
actions: readonly ResolvedCampaignHomeAction[]
actions: readonly CampaignQuickAction[]
}) => {
const [snapPoint, setSnapPoint] = useState<QuickActionsSnapPoint | null>(
QUICK_ACTIONS_SNAP_COLLAPSED,

View file

@ -0,0 +1,113 @@
import { ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
wizardActionHref,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import {
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
type ActivityQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
'update-votes',
'register-signal',
'change-trend',
'update-leadership',
'register-demand',
]
const listQuickActions = (): readonly CampaignQuickAction[] => [
{
id: 'new-activity',
label: 'Nova atividade',
icon: Plus,
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
href: ACTIVITY_NEW_PATH,
},
{
id: 'plan-tour',
label: 'Planejar giro',
icon: MapPinned,
description: 'Compor um giro por território e gerar rascunhos de atividade',
href: ACTIVITY_TOUR_COMPOSER_PATH,
},
]
const wizardActionsWithMunicipalityPrefill = (
role: CampaignRole,
municipalitySlug: string,
): readonly CampaignQuickAction[] => {
const homeActions = homeActionsForRole(role).filter((action) =>
WIZARD_ACTION_IDS.includes(action.id as CampaignWizardActionId),
)
return toHomeActionButtonProps(homeActions).map((action) => {
const wizardId = action.id as CampaignWizardActionId
return {
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: wizardActionHref(CAMPAIGN_WIZARD_ACTION_SLUGS[wizardId], municipalitySlug),
}
})
}
const detailVerticalQuickActions = (activitySlug: string): readonly CampaignQuickAction[] => {
const base = `/campanha/atividades/${activitySlug}`
const actions: CampaignQuickAction[] = [
{
id: 'edit-activity',
label: 'Editar',
icon: Pencil,
description: 'Abrir o formulário completo da atividade',
href: `${base}/editar`,
},
{
id: 'activity-tasks',
label: 'Tarefas',
icon: ListChecks,
description: 'Ver e marcar o checklist desta atividade',
href: `${base}?tab=tasks`,
},
{
id: 'activity-updates',
label: 'Atualizações',
icon: ClipboardList,
description: 'Registrar ou revisar o feed de atualizações',
href: `${base}?tab=updates`,
},
]
return actions
}
export const resolveActivityQuickActions = (
surface: ActivityQuickActionSurface,
role: CampaignRole,
context: CampaignQuickActionContext,
): readonly CampaignQuickAction[] => {
if (!isStaffCampaignRole(role)) return []
if (surface.kind === 'list') {
return listQuickActions()
}
const activitySlug = context.activitySlug ?? surface.activitySlug
const municipalitySlug = context.municipalitySlug
const detailActions = detailVerticalQuickActions(activitySlug)
if (!municipalitySlug) {
return detailActions
}
return [...wizardActionsWithMunicipalityPrefill(role, municipalitySlug), ...detailActions]
}

View file

@ -1,5 +1,6 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isActivityTourComposerPath } from '@/lib/campaignQuickActionPaths'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
/** Início matches only exactly — same rule as sidebar nav. */
@ -19,6 +20,8 @@ export const isLeaderContactsPath = (pathname: string): boolean =>
export const shouldMountQuickActionsDrawer = (pathname: string, role: CampaignRole): boolean => {
if (isCampaignHomePath(pathname)) return false
if (isCampaignActionsPath(pathname)) return false
// B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome.
if (isActivityTourComposerPath(pathname)) return false
if (role === 'leader') return isLeaderContactsPath(pathname)
return isStaffCampaignRole(role)
}

View file

@ -0,0 +1,33 @@
/** Client-safe `/campanha/atividades` path helpers for the B84 quick-action registry. */
export const ACTIVITY_LIST_PATH = '/campanha/atividades' as const
export const ACTIVITY_NEW_PATH = '/campanha/atividades/nova' as const
export const ACTIVITY_TOUR_COMPOSER_PATH = '/campanha/atividades/giros' as const
export type ActivityQuickActionSurface = { kind: 'list' } | { kind: 'detail'; activitySlug: string }
const normalizePathname = (pathname: string): string =>
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
const activityDetailSlug = (pathname: string): string | null => {
const prefix = `${ACTIVITY_LIST_PATH}/`
if (!pathname.startsWith(prefix)) return null
const rest = pathname.slice(prefix.length)
if (!rest || rest.includes('/')) return null
if (rest === 'nova' || rest === 'giros') return null
return rest
}
export const parseActivityQuickActionSurface = (
pathname: string,
): ActivityQuickActionSurface | null => {
const normalized = normalizePathname(pathname)
if (normalized === ACTIVITY_LIST_PATH) return { kind: 'list' }
const slug = activityDetailSlug(normalized)
return slug ? { kind: 'detail', activitySlug: slug } : null
}
export const isActivityTourComposerPath = (pathname: string): boolean =>
normalizePathname(pathname) === ACTIVITY_TOUR_COMPOSER_PATH

View file

@ -1,10 +1,10 @@
import {
resolveStaffHomeQuickActions,
type ResolvedCampaignHomeAction,
} from '@/lib/campaignHomeActions'
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions'
import { resolveMunicipalityQuickActionsForPath } from '@/lib/campaignMunicipalityQuickActions'
import { CAMPAIGN_TERRITORIES_HOME } from '@/lib/campaignPaths'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import { parseActivityQuickActionSurface } from '@/lib/campaignQuickActionPaths'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import type { CampaignRole } from '@/lib/campaignRoles'
const isTerritoriesListPath = (pathname: string): boolean =>
@ -19,7 +19,12 @@ export const resolveQuickActionsForPath = (
pathname: string,
role: CampaignRole,
context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => {
): readonly CampaignQuickAction[] => {
const activitySurface = parseActivityQuickActionSurface(pathname)
if (activitySurface) {
return resolveActivityQuickActions(activitySurface, role, context)
}
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
if (municipalityActions.length > 0) return municipalityActions

View file

@ -0,0 +1,10 @@
import type { LucideIcon } from 'lucide-react'
/** Client-safe quick-action row rendered by the B79 drawer strip. */
export type CampaignQuickAction = {
id: string
label: string
icon: LucideIcon
description: string
href?: string
}

View file

@ -49,7 +49,19 @@ describe('searchHomeMunicipalities (B48)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
let other = await fixtures.getMunicipality()
for (let attempt = 0; attempt < 30; attempt++) {
try {
distinctWordStartQuery(other.name, administered.name)
distinctWordStartQuery(administered.name, other.name)
break
} catch {
other = await fixtures.getMunicipality()
}
if (attempt === 29) {
throw new Error('could not allocate two municipalities with distinct word-start queries')
}
}
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const otherQuery = distinctWordStartQuery(other.name, administered.name)

View file

@ -48,7 +48,19 @@ describe('searchStaffMunicipalityHits (B60)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
let other = await fixtures.getMunicipality()
for (let attempt = 0; attempt < 30; attempt++) {
try {
distinctWordStartQuery(other.name, administered.name)
distinctWordStartQuery(administered.name, other.name)
break
} catch {
other = await fixtures.getMunicipality()
}
if (attempt === 29) {
throw new Error('could not allocate two municipalities with distinct word-start queries')
}
}
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const otherQuery = distinctWordStartQuery(other.name, administered.name)

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
import { UNCOVERED_MUNICIPALITIES_LIST_HREF, homeActionsForRole } from '@/lib/campaignHomeActions'
import {
isCampaignActionsPath,
@ -7,6 +9,13 @@ import {
isLeaderContactsPath,
shouldMountQuickActionsDrawer,
} from '@/lib/campaignQuickActionMount'
import {
ACTIVITY_LIST_PATH,
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
isActivityTourComposerPath,
parseActivityQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import {
QUICK_ACTIONS_SNAP_COLLAPSED,
@ -44,6 +53,73 @@ describe('campaignQuickActionMount', () => {
expect(shouldMountQuickActionsDrawer('/campanha/contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'leader')).toBe(false)
})
it('skips the E13 tour composer (B84)', () => {
expect(isActivityTourComposerPath(ACTIVITY_TOUR_COMPOSER_PATH)).toBe(true)
expect(shouldMountQuickActionsDrawer(ACTIVITY_TOUR_COMPOSER_PATH, 'coordinator')).toBe(false)
})
})
describe('campaignQuickActionPaths (activities)', () => {
it('parses list and detail surfaces', () => {
expect(parseActivityQuickActionSurface(ACTIVITY_LIST_PATH)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface(`${ACTIVITY_LIST_PATH}/`)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface('/campanha/atividades/caminhada-centro')).toEqual({
kind: 'detail',
activitySlug: 'caminhada-centro',
})
})
it('ignores nova, giros and nested edit routes', () => {
expect(parseActivityQuickActionSurface(ACTIVITY_NEW_PATH)).toBeNull()
expect(parseActivityQuickActionSurface(ACTIVITY_TOUR_COMPOSER_PATH)).toBeNull()
expect(parseActivityQuickActionSurface('/campanha/atividades/foo/editar')).toBeNull()
})
})
describe('activityQuickActions (B84)', () => {
it('lists vertical verbs on the activity list', () => {
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
expect(actions[0]?.href).toBe(ACTIVITY_NEW_PATH)
expect(actions[1]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
})
it('prefills wizards and detail shortcuts on activity detail', () => {
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'comicio-feira' },
'coordinator',
{ activitySlug: 'comicio-feira', municipalitySlug: 'feira-de-santana' },
)
expect(actions.some((action) => action.id === 'update-votes')).toBe(true)
expect(
actions
.find((action) => action.id === 'update-votes')
?.href?.includes('municipio=feira-de-santana'),
).toBe(true)
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBe(
'/campanha/atividades/comicio-feira/editar',
)
expect(actions.find((action) => action.id === 'activity-tasks')?.href).toBe(
'/campanha/atividades/comicio-feira?tab=tasks',
)
})
it('omits wizard prefills without municipality context', () => {
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'panfletagem' },
'advisor',
{ activitySlug: 'panfletagem' },
)
expect(actions.some((action) => action.id === 'register-demand')).toBe(false)
expect(actions.map((action) => action.id)).toEqual([
'edit-activity',
'activity-tasks',
'activity-updates',
])
})
})
describe('campaignQuickActionRegistry', () => {
@ -66,6 +142,25 @@ describe('campaignQuickActionRegistry', () => {
)
})
it('delegates activity routes to the B84 catalog', () => {
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
})
it('builds wizard hrefs with municipality slug from context', () => {
const actions = resolveQuickActionsForPath(
'/campanha/atividades/evento-zona-1',
'coordinator',
{
municipalitySlug: 'salvador-ze-01',
},
)
const registerSignal = actions.find((action) => action.id === 'register-signal')
expect(registerSignal?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']}?municipio=salvador-ze-01`,
)
})
it('returns staff Início catalog on territorios without municipality prefill (B81)', () => {
const actions = resolveQuickActionsForPath('/campanha/territorios', 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(staffActionIds)