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 (