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
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user