diff --git a/src/components/campaign/shell/CampaignQuickActionContextBridge.tsx b/src/components/campaign/shell/CampaignQuickActionContextBridge.tsx
new file mode 100644
index 00000000..3102fa82
--- /dev/null
+++ b/src/components/campaign/shell/CampaignQuickActionContextBridge.tsx
@@ -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
+}
diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx
index 537c95a3..2e137d2d 100644
--- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx
+++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx
@@ -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(
QUICK_ACTIONS_SNAP_COLLAPSED,
diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts
new file mode 100644
index 00000000..daeb943c
--- /dev/null
+++ b/src/lib/activityQuickActions.ts
@@ -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]
+}
diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts
index 814bde7b..ff6c502a 100644
--- a/src/lib/campaignQuickActionMount.ts
+++ b/src/lib/campaignQuickActionMount.ts
@@ -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)
}
diff --git a/src/lib/campaignQuickActionPaths.ts b/src/lib/campaignQuickActionPaths.ts
new file mode 100644
index 00000000..fa6a26fa
--- /dev/null
+++ b/src/lib/campaignQuickActionPaths.ts
@@ -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
diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts
index a4dc5c94..3412a497 100644
--- a/src/lib/campaignQuickActionRegistry.ts
+++ b/src/lib/campaignQuickActionRegistry.ts
@@ -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
diff --git a/src/lib/campaignQuickActionTypes.ts b/src/lib/campaignQuickActionTypes.ts
new file mode 100644
index 00000000..10cd88be
--- /dev/null
+++ b/src/lib/campaignQuickActionTypes.ts
@@ -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
+}
diff --git a/tests/int/homeSearchMunicipalities.int.spec.ts b/tests/int/homeSearchMunicipalities.int.spec.ts
index cbbbb447..4e832e00 100644
--- a/tests/int/homeSearchMunicipalities.int.spec.ts
+++ b/tests/int/homeSearchMunicipalities.int.spec.ts
@@ -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)
diff --git a/tests/int/searchStaffMunicipalityHits.int.spec.ts b/tests/int/searchStaffMunicipalityHits.int.spec.ts
index 7a6fcc20..403130e1 100644
--- a/tests/int/searchStaffMunicipalityHits.int.spec.ts
+++ b/tests/int/searchStaffMunicipalityHits.int.spec.ts
@@ -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)
diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts
index 01933a8e..8838f23a 100644
--- a/tests/unit/campaignQuickAction.unit.spec.ts
+++ b/tests/unit/campaignQuickAction.unit.spec.ts
@@ -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)