diff --git a/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx index 97e012f9..d0d53154 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx @@ -1,6 +1,8 @@ import { redirect } from 'next/navigation' -/** Inline create lives on the assessors list — keep old bookmarks working. */ +import { advisorQuickCreateHref } from '@/lib/campaignAdvisorQuickActions' + +/** Inline create lives on the assessores list — keep old bookmarks working. */ export default function AdvisorNewRedirectPage() { - redirect('/campanha/assessores') + redirect(advisorQuickCreateHref) } diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index 9d949ac8..febfa822 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -1,3 +1,5 @@ +import { ADVISOR_QUICK_CREATE_PARAM } from '@/lib/campaignAdvisorQuickActions' +import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths' import config from '@payload-config' import { getPayload } from 'payload' @@ -14,6 +16,7 @@ import { loadAdvisorListPageData, parseAdvisorListParams, } from '@/utilities/advisorData' +import { firstValue } from '@/utilities/campaignListUrl' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { @@ -35,6 +38,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) ]) const state = parseAdvisorListParams(rawSearchParams) + const autoCreateDraft = firstValue(rawSearchParams[ADVISOR_QUICK_CREATE_PARAM]) === '1' const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([ loadAdvisorListPageData(payload, state), loadMunicipalityPortfolioIndex(), @@ -55,7 +59,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) ariaLabel="Buscar assessor por nome ou e-mail" placeholder="Buscar por nome ou e-mail…" initialQuery={state.q ?? ''} - basePath="/campanha/assessores" + basePath={CAMPAIGN_ADVISORS_HOME} /> @@ -67,6 +71,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) municipalitiesAction={setAdvisorMunicipalitiesFormAction} createAction={createAdvisorFormAction} passwordResetAction={sendAdvisorPasswordResetFormAction} + autoCreateDraft={autoCreateDraft} /> {totalDocs > 0 ? ( diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index c6899fa0..88bee994 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useMemo, useState, useTransition } from 'react' +import { useEffect, useMemo, useState, useTransition } from 'react' import { toast } from 'sonner' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' @@ -73,6 +73,7 @@ type AdvisorsTableProps = { state: CampaignFormActionState, formData: FormData, ) => Promise + autoCreateDraft?: boolean } const emptyDraft = (): DraftAdvisor => ({ @@ -95,6 +96,7 @@ export const AdvisorsTable = ({ municipalitiesAction, createAction, passwordResetAction, + autoCreateDraft = false, }: AdvisorsTableProps) => { const router = useRouter() const [editing, setEditing] = useState(false) @@ -115,6 +117,10 @@ export const AdvisorsTable = ({ setDraft(emptyDraft()) } + useEffect(() => { + if (autoCreateDraft) setDraft(emptyDraft()) + }, [autoCreateDraft]) + const cancelDraft = () => setDraft(null) const leaveEditing = () => { diff --git a/src/lib/campaignAdvisorQuickActions.ts b/src/lib/campaignAdvisorQuickActions.ts new file mode 100644 index 00000000..1043a05c --- /dev/null +++ b/src/lib/campaignAdvisorQuickActions.ts @@ -0,0 +1,67 @@ +import { UserPlus } from 'lucide-react' + +import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths' +import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' +import { isUnrestrictedCampaignRole, type CampaignRole } from '@/lib/campaignRoles' + +export const ADVISOR_QUICK_CREATE_PARAM = 'criar' as const + +export const advisorQuickCreateHref = + `${CAMPAIGN_ADVISORS_HOME}?${ADVISOR_QUICK_CREATE_PARAM}=1` as const + +const advisorDetailPathPattern = /^\/campanha\/assessores\/(\d+)(?:\/|$)/ + +export const isAdvisorsListPath = (pathname: string): boolean => + pathname === CAMPAIGN_ADVISORS_HOME || pathname === `${CAMPAIGN_ADVISORS_HOME}/` + +export const isAdvisorsPath = (pathname: string): boolean => + isAdvisorsListPath(pathname) || parseAdvisorDetailId(pathname) !== undefined + +export const parseAdvisorDetailId = (pathname: string): number | undefined => { + const match = pathname.match(advisorDetailPathPattern) + if (!match) return undefined + const id = Number(match[1]) + return Number.isInteger(id) && id > 0 ? id : undefined +} + +const newAdvisorAction: CampaignQuickAction = { + id: 'new-advisor', + label: 'Novo assessor', + icon: UserPlus, + description: 'Criar conta de assessor na tabela', + href: advisorQuickCreateHref, +} + +const advisorQuickActionsByRole = new Map() + +const advisorQuickActionsForRole = (role: CampaignRole): readonly CampaignQuickAction[] => { + if (!isUnrestrictedCampaignRole(role)) return [] + const cached = advisorQuickActionsByRole.get(role) + if (cached) return cached + const actions = [newAdvisorAction] as const + advisorQuickActionsByRole.set(role, actions) + return actions +} + +export const resolveAdvisorListQuickActions = ( + role: CampaignRole, +): readonly CampaignQuickAction[] => advisorQuickActionsForRole(role) + +export const resolveAdvisorDetailQuickActions = ( + role: CampaignRole, +): readonly CampaignQuickAction[] => advisorQuickActionsForRole(role) + +export const resolveAdvisorQuickActionsForPath = ( + pathname: string, + role: CampaignRole, + _context: CampaignQuickActionContext, +): readonly CampaignQuickAction[] => { + if (!isUnrestrictedCampaignRole(role)) return [] + + if (isAdvisorsListPath(pathname) || parseAdvisorDetailId(pathname) !== undefined) { + return advisorQuickActionsForRole(role) + } + + return [] +} diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index d31f60f1..3500430d 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -5,4 +5,6 @@ export const CAMPAIGN_TERRITORIES_HOME = '/campanha/territorios' as const export const CAMPAIGN_DEMANDS_HOME = '/campanha/demandas' as const +export const CAMPAIGN_ADVISORS_HOME = '/campanha/assessores' as const + export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index ff6c502a..ecd8daa2 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -1,7 +1,12 @@ import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes' +import { isAdvisorsPath } from '@/lib/campaignAdvisorQuickActions' import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import { isActivityTourComposerPath } from '@/lib/campaignQuickActionPaths' -import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' +import { + isStaffCampaignRole, + isUnrestrictedCampaignRole, + type CampaignRole, +} from '@/lib/campaignRoles' /** Início matches only exactly — same rule as sidebar nav. */ export const isCampaignHomePath = (pathname: string): boolean => @@ -23,5 +28,6 @@ export const shouldMountQuickActionsDrawer = (pathname: string, role: CampaignRo // B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome. if (isActivityTourComposerPath(pathname)) return false if (role === 'leader') return isLeaderContactsPath(pathname) + if (isAdvisorsPath(pathname)) return isUnrestrictedCampaignRole(role) return isStaffCampaignRole(role) } diff --git a/src/lib/campaignQuickActionRegistry.ts b/src/lib/campaignQuickActionRegistry.ts index 82f3b170..06643fff 100644 --- a/src/lib/campaignQuickActionRegistry.ts +++ b/src/lib/campaignQuickActionRegistry.ts @@ -1,4 +1,5 @@ import { resolveActivityQuickActions } from '@/lib/activityQuickActions' +import { resolveAdvisorQuickActionsForPath } from '@/lib/campaignAdvisorQuickActions' import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions' import { resolveMunicipalityQuickActionsForPath } from '@/lib/campaignMunicipalityQuickActions' import { CAMPAIGN_TERRITORIES_HOME } from '@/lib/campaignPaths' @@ -56,6 +57,9 @@ export const resolveQuickActionsForPath = ( const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context) if (municipalityActions.length > 0) return municipalityActions + const advisorActions = resolveAdvisorQuickActionsForPath(pathname, role, context) + if (advisorActions.length > 0) return advisorActions + if (isTerritoriesListPath(pathname)) { return resolveStaffHomeQuickActions(role) } diff --git a/tests/unit/campaignAdvisorQuickActions.unit.spec.ts b/tests/unit/campaignAdvisorQuickActions.unit.spec.ts new file mode 100644 index 00000000..91cea3c9 --- /dev/null +++ b/tests/unit/campaignAdvisorQuickActions.unit.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { + advisorQuickCreateHref, + isAdvisorsListPath, + isAdvisorsPath, + parseAdvisorDetailId, + resolveAdvisorDetailQuickActions, + resolveAdvisorListQuickActions, + resolveAdvisorQuickActionsForPath, +} from '@/lib/campaignAdvisorQuickActions' + +describe('campaignAdvisorQuickActions paths', () => { + it('matches assessores list exactly', () => { + expect(isAdvisorsListPath('/campanha/assessores')).toBe(true) + expect(isAdvisorsListPath('/campanha/assessores/')).toBe(true) + expect(isAdvisorsListPath('/campanha/assessores/novo')).toBe(false) + }) + + it('matches assessores detail ids', () => { + expect(parseAdvisorDetailId('/campanha/assessores/42')).toBe(42) + expect(parseAdvisorDetailId('/campanha/assessores/42/')).toBe(42) + expect(parseAdvisorDetailId('/campanha/assessores/novo')).toBeUndefined() + expect(isAdvisorsPath('/campanha/assessores/42')).toBe(true) + }) +}) + +describe('resolveAdvisorListQuickActions', () => { + it('returns new-advisor for unrestricted roles', () => { + const actions = resolveAdvisorListQuickActions('coordinator') + expect(actions).toHaveLength(1) + expect(actions[0]?.id).toBe('new-advisor') + expect(actions[0]?.href).toBe(advisorQuickCreateHref) + }) + + it('returns empty catalog for advisor lockdown', () => { + expect(resolveAdvisorListQuickActions('advisor')).toEqual([]) + }) +}) + +describe('resolveAdvisorDetailQuickActions', () => { + it('returns new-advisor for unrestricted roles', () => { + const actions = resolveAdvisorDetailQuickActions('candidate') + expect(actions.map((action) => action.id)).toEqual(['new-advisor']) + expect(actions[0]?.href).toBe(advisorQuickCreateHref) + }) +}) + +describe('resolveAdvisorQuickActionsForPath', () => { + it('delegates list and detail catalogs (B87)', () => { + const list = resolveAdvisorQuickActionsForPath('/campanha/assessores', 'coordinator', {}) + expect(list.map((action) => action.id)).toEqual(['new-advisor']) + + const detail = resolveAdvisorQuickActionsForPath('/campanha/assessores/7', 'coordinator', {}) + expect(detail.map((action) => action.id)).toEqual(['new-advisor']) + }) + + it('returns empty catalog for advisor role even on assessores paths', () => { + expect(resolveAdvisorQuickActionsForPath('/campanha/assessores', 'advisor', {})).toEqual([]) + }) +}) diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index abbe0c7f..4c906c9b 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveActivityQuickActions } from '@/lib/activityQuickActions' import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes' +import { advisorQuickCreateHref } from '@/lib/campaignAdvisorQuickActions' import { UNCOVERED_MUNICIPALITIES_LIST_HREF, homeActionsForRole } from '@/lib/campaignHomeActions' import { CAMPAIGN_DEMANDS_CREATE_HREF, @@ -72,6 +73,13 @@ describe('campaignQuickActionMount', () => { expect(isActivityTourComposerPath(ACTIVITY_TOUR_COMPOSER_PATH)).toBe(true) expect(shouldMountQuickActionsDrawer(ACTIVITY_TOUR_COMPOSER_PATH, 'coordinator')).toBe(false) }) + + it('mounts assessores only for unrestricted roles (B87)', () => { + expect(shouldMountQuickActionsDrawer('/campanha/assessores', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsDrawer('/campanha/assessores/12', 'candidate')).toBe(true) + expect(shouldMountQuickActionsDrawer('/campanha/assessores', 'advisor')).toBe(false) + expect(shouldMountQuickActionsDrawer('/campanha/assessores/12', 'advisor')).toBe(false) + }) }) describe('campaignQuickActionPaths (activities)', () => { @@ -279,6 +287,20 @@ describe('campaignQuickActionRegistry', () => { resolveQuickActionsForPath('/campanha/demandas/pedido-cairu', 'coordinator', {}), ).toEqual([]) }) + + it('delegates assessores list and detail catalogs (B87)', () => { + const list = resolveQuickActionsForPath('/campanha/assessores', 'coordinator', {}) + expect(list.map((action) => action.id)).toEqual(['new-advisor']) + expect(list[0]?.href).toBe(advisorQuickCreateHref) + + const detail = resolveQuickActionsForPath('/campanha/assessores/9', 'candidate', {}) + expect(detail.map((action) => action.id)).toEqual(['new-advisor']) + expect(detail[0]?.href).toBe(advisorQuickCreateHref) + }) + + it('returns empty catalog on assessores for advisor lockdown', () => { + expect(resolveQuickActionsForPath('/campanha/assessores', 'advisor', {})).toEqual([]) + }) }) describe('campaignQuickActionDemands paths', () => {