B84 — Ações rápidas na vertical Atividades (lista + detalhe)

Registra catálogo B84 no registry B79: Nova atividade e Planejar giro na
lista; wizards A1–A5 com ?municipio= no detalhe quando há município, mais
atalhos Editar/Tarefas/Atualizações. Context bridge no detalhe; drawer
omitido em /atividades/giros (compositor E13 já tem CTAs).

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 04:08:14 +00:00
parent 82ca33c3d3
commit eea2d9afa1
No known key found for this signature in database
9 changed files with 330 additions and 3 deletions

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,7 @@ 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 type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import {
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_EXPANDED,
@ -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 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 { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
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,4 +1,5 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { isActivityTourComposerPath } from '@/lib/campaignQuickActionPaths'
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
@ -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,35 @@
/** 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,9 +1,15 @@
<<<<<<< HEAD
import {
resolveStaffHomeQuickActions,
type ResolvedCampaignHomeAction,
} from '@/lib/campaignHomeActions'
import { CAMPAIGN_TERRITORIES_HOME } from '@/lib/campaignPaths'
=======
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
>>>>>>> ccc5faf (B84 — Ações rápidas na vertical Atividades (lista + detalhe))
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 =>
@ -12,15 +18,25 @@ const isTerritoriesListPath = (pathname: string): boolean =>
/**
* Pathname + role + page context → contextual quick actions.
* Catalogs per vertical land in B80–B90; the B79 chassis renders whatever
* this helper returns (empty until those items register providers).
* this helper returns.
*/
export const resolveQuickActionsForPath = (
pathname: string,
role: CampaignRole,
<<<<<<< HEAD
_context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => {
if (isTerritoriesListPath(pathname)) {
return resolveStaffHomeQuickActions(role)
}
=======
context: CampaignQuickActionContext,
): readonly CampaignQuickAction[] => {
const activitySurface = parseActivityQuickActionSurface(pathname)
if (activitySurface) {
return resolveActivityQuickActions(activitySurface, role, context)
}
>>>>>>> ccc5faf (B84 — Ações rápidas na vertical Atividades (lista + detalhe))
return []
}

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

@ -1,6 +1,17 @@
import { describe, expect, it } from 'vitest'
<<<<<<< HEAD
import { UNCOVERED_MUNICIPALITIES_LIST_HREF, homeActionsForRole } from '@/lib/campaignHomeActions'
=======
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import {
ACTIVITY_LIST_PATH,
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
isActivityTourComposerPath,
parseActivityQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
>>>>>>> ccc5faf (B84 — Ações rápidas na vertical Atividades (lista + detalhe))
import {
isCampaignActionsPath,
isCampaignHomePath,
@ -13,6 +24,7 @@ import {
QUICK_ACTIONS_SNAP_EXPANDED,
quickActionsSnapIsExpanded,
} from '@/lib/campaignQuickActionSnap'
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
describe('campaignQuickActionMount', () => {
it('treats Início as exact match only', () => {
@ -44,9 +56,75 @@ 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', () => {
<<<<<<< HEAD
const staffActionIds = homeActionsForRole('coordinator').map((action) => action.id)
it('returns empty catalog for unregistered paths', () => {
@ -55,6 +133,27 @@ describe('campaignQuickActionRegistry', () => {
municipalitySlug: 'foo',
}),
).toEqual([])
=======
it('returns empty catalog on unrelated staff routes', () => {
expect(resolveQuickActionsForPath('/campanha/municipios/foo', 'coordinator', {})).toEqual([])
})
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`,
)
>>>>>>> ccc5faf (B84 — Ações rápidas na vertical Atividades (lista + detalhe))
})
it('returns staff Início catalog on territorios without municipality prefill (B81)', () => {