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..ef039786 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -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( QUICK_ACTIONS_SNAP_COLLAPSED, diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts new file mode 100644 index 00000000..6c68821e --- /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 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] +} \ No newline at end of file diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index 814bde7b..7cd0b029 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -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) } diff --git a/src/lib/campaignQuickActionPaths.ts b/src/lib/campaignQuickActionPaths.ts new file mode 100644 index 00000000..ba9ce220 --- /dev/null +++ b/src/lib/campaignQuickActionPaths.ts @@ -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 diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts index dd8f9f9d..edcc0819 100644 --- a/src/lib/campaignQuickActionRegistry.ts +++ b/src/lib/campaignQuickActionRegistry.ts @@ -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 [] } 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/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index 0d59a4e6..f97c4fa9 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -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)', () => {