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

@@ -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"