Multi-role members, round detail UI overhaul, dashboard jury progress, and submit bug fix
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Add roles UserRole[] to User model with migration + backfill from existing role column
- Update auth JWT/session to propagate roles array with [role] fallback for stale tokens
- Update tRPC hasRole() middleware and add userHasRole() helper for inline role checks
- Update ~15 router inline checks and ~13 DB queries to use roles array
- Add updateRoles admin mutation with SUPER_ADMIN guard and priority-based primary role
- Add role switcher UI in admin sidebar and role-nav for multi-role users
- Remove redundant stats cards from round detail, add window dates to header banner
- Merge Members section into JuryProgressTable with inline cap editor and remove buttons
- Reorder round detail assignments tab: Progress > Score Dist > Assignments > Coverage > Jury Group
- Make score distribution fill full vertical height, reassignment history always open
- Add per-juror progress bars to admin dashboard ActiveRoundPanel for EVALUATION rounds
- Fix evaluation submit bug: use isSubmitting state instead of startMutation.isPending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:44:55 +01:00
parent 230347005c
commit f3fd9eebee
25 changed files with 963 additions and 714 deletions

View File

@@ -13,14 +13,44 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
export type JuryProgressTableMember = {
id: string
userId: string
name: string
email: string
maxAssignmentsOverride: number | null
}
export type JuryProgressTableProps = {
roundId: string
members?: JuryProgressTableMember[]
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
onAddMember?: () => void
}
export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
export function JuryProgressTable({
roundId,
members,
onSaveCap,
onRemoveMember,
onAddMember,
}: JuryProgressTableProps) {
const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
@@ -52,12 +82,30 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
onError: (err) => toast.error(err.message),
})
const hasMembersData = members !== undefined
return (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle>
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{hasMembersData ? 'Jury Members & Progress' : 'Jury Progress'}
</CardTitle>
<CardDescription>
{hasMembersData
? 'Manage jury members, caps, and evaluation progress per juror.'
: 'Evaluation completion per juror. Click the mail icon to notify an individual juror.'}
</CardDescription>
</div>
{onAddMember && (
<Button size="sm" onClick={onAddMember}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -65,11 +113,28 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !workload || workload.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No assignments yet
</p>
hasMembersData && members && members.length > 0 ? (
// Show members-only view when there are members but no assignments yet
<div className="space-y-1">
{members.map((member, idx) => (
<MemberOnlyRow
key={member.id}
member={member}
idx={idx}
roundId={roundId}
onSaveCap={onSaveCap}
onRemoveMember={onRemoveMember}
notifyMutation={notifyMutation}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
{hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'}
</p>
)
) : (
<div className="space-y-3 max-h-[350px] overflow-y-auto">
<div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
{workload.map((juror) => {
const pct = juror.completionRate
const barGradient = pct === 100
@@ -80,11 +145,23 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
? 'bg-gradient-to-r from-amber-400 to-amber-600'
: 'bg-gray-300'
// Find the corresponding member entry for cap editing
const member = members?.find((m) => m.userId === juror.id)
return (
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
<div className="flex justify-between items-center text-xs">
<span className="font-medium truncate max-w-[50%]">{juror.name}</span>
<span className="font-medium truncate max-w-[140px]">{juror.name}</span>
<div className="flex items-center gap-2 shrink-0">
{member && onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<span className="text-muted-foreground tabular-nums">
{juror.completed}/{juror.assigned} ({pct}%)
</span>
@@ -151,6 +228,37 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{member && onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
@@ -178,3 +286,96 @@ export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
</>
)
}
// Sub-component for member-only rows (no workload data yet)
function MemberOnlyRow({
member,
idx,
roundId,
onSaveCap,
onRemoveMember,
notifyMutation,
}: {
member: JuryProgressTableMember
idx: number
roundId: string
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
notifyMutation: ReturnType<typeof trpc.assignment.notifySingleJurorOfAssignments.useMutation>
}) {
return (
<div
className={cn(
'flex items-center justify-between py-2 px-2 rounded-md transition-colors text-xs',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{member.name}</p>
<p className="text-muted-foreground truncate">{member.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMutation.isPending && notifyMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
}

View File

@@ -1,39 +1,30 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { History, ChevronRight } from 'lucide-react'
import { History } from 'lucide-react'
export type ReassignmentHistoryProps = {
roundId: string
}
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
const [expanded, setExpanded] = useState(false)
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
{ roundId },
{ enabled: expanded },
)
return (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<History className="h-4 w-4" />
Reassignment History
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
</CardTitle>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
</CardHeader>
{expanded && (
<CardContent>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
@@ -105,7 +96,6 @@ export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@@ -364,9 +364,16 @@ export function MembersContent() {
</div>
</TableCell>
<TableCell>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
</Badge>
<div className="flex flex-wrap gap-1">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? (
@@ -469,9 +476,16 @@ export function MembersContent() {
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
</Badge>
<div className="flex flex-wrap gap-1 justify-end">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>

View File

@@ -21,16 +21,16 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
[dist])
return (
<Card>
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">Score Distribution</CardTitle>
<CardDescription>
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col flex-1 pb-4">
{isLoading ? (
<div className="flex items-end gap-1 h-32">
<div className="flex items-end gap-1 flex-1 min-h-[120px]">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
</div>
) : !dist || dist.totalEvaluations === 0 ? (
@@ -38,7 +38,7 @@ export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
No evaluations submitted yet
</p>
) : (
<div className="flex gap-1 h-32">
<div className="flex gap-1 flex-1 min-h-[120px]">
{dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100
return (

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { motion } from 'motion/react'
import {
@@ -10,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
@@ -19,6 +21,7 @@ import {
import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
import { trpc } from '@/lib/trpc/client'
export type PipelineRound = {
id: string
@@ -138,6 +141,80 @@ function ProjectStateBar({
)
}
function EvaluationRoundContent({ round }: { round: PipelineRound }) {
const [showAll, setShowAll] = useState(false)
const { data: workload, isLoading: isLoadingWorkload } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId: round.id },
{ enabled: round.roundType === 'EVALUATION' }
)
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
{/* Per-juror progress */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Jury Progress</span>
{workload && workload.length > 8 && (
<button
onClick={() => setShowAll(!showAll)}
className="text-xs text-primary hover:underline"
>
{showAll ? 'Show less' : `Show all (${workload.length})`}
</button>
)}
</div>
{isLoadingWorkload ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : workload && workload.length > 0 ? (
<div className="space-y-1">
{(showAll ? workload : workload.slice(0, 8)).map((juror) => {
const pct = juror.assigned > 0 ? (juror.completed / juror.assigned) * 100 : 0
return (
<div key={juror.id} className="flex items-center gap-2">
<span className="max-w-[140px] truncate text-xs">{juror.name}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{juror.completed}/{juror.assigned}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No jurors assigned yet</p>
)}
</div>
</div>
)
}
function RoundTypeContent({ round }: { round: PipelineRound }) {
const { projectStates } = round
@@ -171,29 +248,8 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
)
}
case 'EVALUATION': {
const pct =
round.evalTotal > 0
? Math.round((round.evalSubmitted / round.evalTotal) * 100)
: 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Evaluation progress</span>
<span className="font-medium">
{round.evalSubmitted} / {round.evalTotal} ({pct}%)
</span>
</div>
<Progress value={pct} gradient />
{round.evalDraft > 0 && (
<p className="text-xs text-amber-600">
{round.evalDraft} draft{round.evalDraft !== 1 ? 's' : ''} in progress
</p>
)}
</div>
)
}
case 'EVALUATION':
return <EvaluationRoundContent round={round} />
case 'SUBMISSION':
return (

View File

@@ -35,7 +35,10 @@ import {
LayoutTemplate,
Layers,
Scale,
Eye,
ArrowRightLeft,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
@@ -147,12 +150,21 @@ const roleLabels: Record<string, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
OBSERVER: 'Observer',
MENTOR: 'Mentor',
AWARD_MASTER: 'Award Master',
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
}
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
@@ -162,6 +174,12 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
const isSuperAdmin = user.role === 'SUPER_ADMIN'
const roleLabel = roleLabels[user.role || ''] || 'User'
// Roles the user can switch to (non-admin roles they hold)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS).filter(
([role]) => userRoles.includes(role as UserRole)
)
// Build dynamic admin nav with current edition's apply page
const dynamicAdminNav = adminNavigation.map((item) => {
if (item.name === 'Apply Page' && currentEdition?.id) {
@@ -344,6 +362,29 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
</Link>
</DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<div className="px-2 py-1.5">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
</div>
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link
href={opt.path as Route}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
>
<opt.icon className="h-4 w-4 text-muted-foreground" />
<span>{opt.label}</span>
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem

View File

@@ -17,7 +17,11 @@ import {
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
@@ -45,6 +49,15 @@ type RoleNavProps = {
editionSelector?: React.ReactNode
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
@@ -52,7 +65,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
@@ -61,6 +74,13 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Roles the user can switch to (excluding current view)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath)
// Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin)
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
@@ -136,6 +156,19 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
Settings
</Link>
</DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator />
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
<opt.icon className="mr-2 h-4 w-4" />
{opt.label}
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
@@ -198,6 +231,25 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{editionSelector}
</div>
)}
{switchableRoles.length > 0 && (
<div className="border-t pt-4 mt-4 space-y-1">
<p className="flex items-center gap-1.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
{switchableRoles.map(([, opt]) => (
<Link
key={opt.path}
href={opt.path as Route}
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<opt.icon className="h-4 w-4" />
{opt.label}
</Link>
))}
</div>
)}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"