diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 4b5779dc..66ba5233 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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'], }, diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index c00e8275..da5cdeb6 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -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 (
+
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)