MOPC-App/src/app/(admin)/admin/rounds/[id]/assignments/page.tsx

893 lines
34 KiB
TypeScript
Raw Normal View History

'use client'
import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ArrowLeft,
Users,
FileText,
CheckCircle2,
Clock,
AlertCircle,
Sparkles,
Loader2,
Plus,
Trash2,
RefreshCw,
UserPlus,
} from 'lucide-react'
import { toast } from 'sonner'
interface PageProps {
params: Promise<{ id: string }>
}
function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const [manualDialogOpen, setManualDialogOpen] = useState(false)
const [selectedJuror, setSelectedJuror] = useState<string>('')
const [selectedProject, setSelectedProject] = useState<string>('')
const [useAI, setUseAI] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: isAIAvailable } = trpc.assignment.isAIAvailable.useQuery()
// AI Assignment job queries
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
{ roundId },
{ enabled: useAI }
)
// Poll for job status when there's an active job
const { data: jobStatus } = trpc.assignment.getAIAssignmentJobStatus.useQuery(
{ jobId: activeJobId! },
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
}
)
// Start AI assignment job mutation
const startAIJob = trpc.assignment.startAIAssignmentJob.useMutation()
const isAIJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
const aiJobProgressPercent = jobStatus?.totalBatches
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
: 0
// Algorithmic suggestions (default)
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
{ roundId },
{ enabled: !!round && !useAI }
)
// AI-powered suggestions (expensive - only used after job completes)
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
{ roundId, useAI: true },
{
enabled: !!round && useAI && !isAIJobRunning,
staleTime: Infinity, // Never consider stale (only refetch manually)
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
}
)
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
setActiveJobId(latestJob.id)
}
}, [latestJob])
// Handle job completion
useEffect(() => {
if (jobStatus?.status === 'COMPLETED') {
toast.success(
`AI Assignment complete: ${jobStatus.suggestionsCount} suggestions generated${jobStatus.fallbackUsed ? ' (using fallback algorithm)' : ''}`
)
setActiveJobId(null)
refetchLatestJob()
refetchAI()
} else if (jobStatus?.status === 'FAILED') {
toast.error(`AI Assignment failed: ${jobStatus.errorMessage || 'Unknown error'}`)
setActiveJobId(null)
refetchLatestJob()
}
}, [jobStatus?.status, jobStatus?.suggestionsCount, jobStatus?.fallbackUsed, jobStatus?.errorMessage, refetchLatestJob, refetchAI])
const handleStartAIJob = async () => {
try {
const result = await startAIJob.mutateAsync({ roundId })
setActiveJobId(result.jobId)
toast.info('AI Assignment job started. Progress will update automatically.')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to start AI assignment'
)
}
}
// Normalize AI suggestions to match algorithmic format
const aiSuggestions = aiSuggestionsRaw?.suggestions?.map((s) => ({
userId: s.jurorId,
jurorName: s.jurorName,
projectId: s.projectId,
projectTitle: s.projectTitle,
score: Math.round(s.confidenceScore * 100),
reasoning: [s.reasoning],
})) ?? []
// Use the appropriate suggestions based on mode
const suggestions = useAI ? aiSuggestions : (algorithmicSuggestions ?? [])
const loadingSuggestions = useAI ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
const refetchSuggestions = useAI ? refetchAI : refetchAlgorithmic
// Get available jurors for manual assignment
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: manualDialogOpen }
)
// Get projects in this round for manual assignment
const { data: roundProjects } = trpc.project.list.useQuery(
{ roundId, perPage: 500 },
{ enabled: manualDialogOpen }
)
const utils = trpc.useUtils()
const deleteAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
},
})
const applySuggestions = trpc.assignment.applySuggestions.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId })
utils.assignment.getAISuggestions.invalidate({ roundId })
setSelectedSuggestions(new Set())
},
})
const createAssignment = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId })
setManualDialogOpen(false)
setSelectedJuror('')
setSelectedProject('')
toast.success('Assignment created successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to create assignment')
},
})
const handleCreateManualAssignment = () => {
if (!selectedJuror || !selectedProject) {
toast.error('Please select both a juror and a project')
return
}
createAssignment.mutate({
userId: selectedJuror,
projectId: selectedProject,
roundId,
})
}
if (loadingRound || loadingAssignments) {
return <AssignmentsSkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const handleToggleSuggestion = (key: string) => {
setSelectedSuggestions((prev) => {
const newSet = new Set(prev)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
return newSet
})
}
const handleSelectAllSuggestions = () => {
if (suggestions) {
if (selectedSuggestions.size === suggestions.length) {
setSelectedSuggestions(new Set())
} else {
setSelectedSuggestions(
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`))
)
}
}
}
const handleApplySelected = async () => {
if (!suggestions) return
const selected = suggestions.filter((s) =>
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
)
await applySuggestions.mutateAsync({
roundId,
assignments: selected.map((s) => ({
userId: s.userId,
projectId: s.projectId,
reasoning: s.reasoning.join('; '),
})),
})
}
// Group assignments by project
const assignmentsByProject = assignments?.reduce((acc, assignment) => {
const projectId = assignment.project.id
if (!acc[projectId]) {
acc[projectId] = {
project: assignment.project,
assignments: [],
}
}
acc[projectId].assignments.push(assignment)
return acc
}, {} as Record<string, { project: (typeof assignments)[0]['project'], assignments: typeof assignments }>) || {}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Manage Judge Assignments
</h1>
</div>
{/* Manual Assignment Button */}
<Dialog open={manualDialogOpen} onOpenChange={setManualDialogOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Manual Assignment
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Manual Assignment</DialogTitle>
<DialogDescription>
Assign a jury member to evaluate a specific project
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Juror Select */}
<div className="space-y-2">
<Label htmlFor="juror">Jury Member</Label>
<Select value={selectedJuror} onValueChange={setSelectedJuror}>
<SelectTrigger id="juror">
<SelectValue placeholder="Select a jury member..." />
</SelectTrigger>
<SelectContent>
{availableJurors?.map((juror) => {
const maxAllowed = juror.maxAssignments ?? 10
const isFull = juror.currentAssignments >= maxAllowed
return (
<SelectItem key={juror.id} value={juror.id} disabled={isFull}>
<div className="flex items-center justify-between gap-4">
<span className={isFull ? 'opacity-50' : ''}>{juror.name || juror.email}</span>
<span className="text-xs text-muted-foreground">
{juror.currentAssignments}/{maxAllowed} assigned
</span>
</div>
</SelectItem>
)
})}
{availableJurors?.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
No jury members available
</div>
)}
</SelectContent>
</Select>
{selectedJuror && availableJurors && (
<p className="text-xs text-muted-foreground">
{(() => {
const juror = availableJurors.find(j => j.id === selectedJuror)
if (!juror) return null
const available = (juror.maxAssignments ?? 10) - juror.currentAssignments
return `${available} assignment slot${available !== 1 ? 's' : ''} available`
})()}
</p>
)}
</div>
{/* Project Select */}
<div className="space-y-2">
<Label htmlFor="project">Project</Label>
<Select value={selectedProject} onValueChange={setSelectedProject}>
<SelectTrigger id="project">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{roundProjects?.projects.map((project) => {
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
const isAlreadyAssigned = selectedJuror && assignments?.some(
a => a.userId === selectedJuror && a.projectId === project.id
)
return (
<SelectItem
key={project.id}
value={project.id}
disabled={!!isAlreadyAssigned}
>
<div className="flex items-center justify-between gap-4">
<span className={isAlreadyAssigned ? 'line-through opacity-50' : ''}>
{project.title}
</span>
<span className="text-xs text-muted-foreground">
{assignmentCount}/{round.requiredReviews} reviewers
</span>
</div>
</SelectItem>
)
})}
{roundProjects?.projects.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
No projects in this round
</div>
)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setManualDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateManualAssignment}
disabled={!selectedJuror || !selectedProject || createAssignment.isPending}
>
{createAssignment.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Assignment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Stats */}
{stats && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Judge Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedAssignments}</div>
<p className="text-xs text-muted-foreground">
{stats.completionPercentage}% complete
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects Covered</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.projectsWithFullCoverage}/{stats.totalProjects}
</div>
<p className="text-xs text-muted-foreground">
{stats.coveragePercentage}% have {round.requiredReviews}+ reviews
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.juryMembersAssigned}</div>
<p className="text-xs text-muted-foreground">assigned to projects</p>
</CardContent>
</Card>
</div>
)}
{/* Coverage Progress */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Coverage</CardTitle>
<CardDescription>
{stats.projectsWithFullCoverage} of {stats.totalProjects} projects have
at least {round.requiredReviews} reviewers assigned
</CardDescription>
</CardHeader>
<CardContent>
<Progress value={stats.coveragePercentage} className="h-3" />
</CardContent>
</Card>
)}
{/* Smart Suggestions */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
{useAI ? 'AI Assignment Suggestions' : 'Smart Assignment Suggestions'}
</CardTitle>
<CardDescription>
{useAI
? 'GPT-powered recommendations analyzing project descriptions and judge expertise'
: 'Algorithmic recommendations based on tag matching and workload balance'}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant={useAI ? 'default' : 'outline'}
size="sm"
onClick={() => {
if (!useAI) {
setUseAI(true)
setSelectedSuggestions(new Set())
// Start AI job if no suggestions yet
if (!aiSuggestionsRaw?.suggestions?.length && !isAIJobRunning) {
handleStartAIJob()
}
} else {
setUseAI(false)
setSelectedSuggestions(new Set())
}
}}
disabled={(!isAIAvailable && !useAI) || isAIJobRunning}
title={!isAIAvailable ? 'OpenAI API key not configured' : undefined}
>
<Sparkles className={`mr-2 h-4 w-4 ${useAI ? 'text-amber-300' : ''}`} />
{useAI ? 'AI Mode' : 'Use AI'}
</Button>
{useAI && !isAIJobRunning && (
<Button
variant="outline"
size="sm"
onClick={handleStartAIJob}
disabled={startAIJob.isPending}
title="Run AI analysis again"
>
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Re-analyze
</Button>
)}
{!useAI && (
<Button
variant="outline"
size="sm"
onClick={() => refetchSuggestions()}
disabled={loadingSuggestions}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{/* AI Job Progress Indicator */}
{isAIJobRunning && jobStatus && (
<div className="mb-4 p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
AI Assignment Analysis in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
</p>
</div>
<Badge variant="outline" className="border-blue-300 text-blue-700">
<Clock className="mr-1 h-3 w-3" />
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
</span>
<span className="font-medium text-blue-900 dark:text-blue-100">
{aiJobProgressPercent}%
</span>
</div>
<Progress value={aiJobProgressPercent} className="h-2" />
</div>
</div>
</div>
)}
{loadingSuggestions && !isAIJobRunning ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : suggestions && suggestions.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedSuggestions.size === suggestions.length}
onCheckedChange={handleSelectAllSuggestions}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={handleApplySelected}
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
>
{applySuggestions.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Apply Selected ({selectedSuggestions.size})
</Button>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Juror</TableHead>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Reasoning</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const key = `${suggestion.userId}-${suggestion.projectId}`
const isSelected = selectedSuggestions.has(key)
return (
<TableRow
key={key}
className={isSelected ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSuggestion(key)}
/>
</TableCell>
<TableCell className="font-medium">
{suggestion.jurorName}
</TableCell>
<TableCell>
{suggestion.projectTitle}
</TableCell>
<TableCell>
<Badge
variant={
suggestion.score >= 60
? 'default'
: suggestion.score >= 40
? 'secondary'
: 'outline'
}
>
{suggestion.score.toFixed(0)}
</Badge>
</TableCell>
<TableCell className="max-w-xs">
<ul className="text-xs text-muted-foreground">
{suggestion.reasoning.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
<p className="mt-2 font-medium">All projects are covered!</p>
<p className="text-sm text-muted-foreground">
No additional assignments are needed at this time
</p>
</div>
)}
</CardContent>
</Card>
{/* Current Judge Assignments */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Judge Assignments</CardTitle>
<CardDescription>
View and manage existing project-to-judge assignments
</CardDescription>
</CardHeader>
<CardContent>
{Object.keys(assignmentsByProject).length > 0 ? (
<div className="space-y-6">
{Object.entries(assignmentsByProject).map(
([projectId, { project, assignments: projectAssignments }]) => (
<div key={projectId} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{project.title}</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{projectAssignments.length} reviewer
{projectAssignments.length !== 1 ? 's' : ''}
</Badge>
{projectAssignments.length >= round.requiredReviews && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Full coverage
</Badge>
)}
</div>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-2">
{projectAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-2">
<span className="text-sm">
{assignment.user.name || assignment.user.email}
</span>
{assignment.evaluation?.status === 'SUBMITTED' ? (
<Badge variant="default" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : assignment.evaluation?.status === 'DRAFT' ? (
<Badge variant="secondary" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
) : (
<Badge variant="outline" className="text-xs">
Pending
</Badge>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={
assignment.evaluation?.status === 'SUBMITTED'
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove Assignment?
</AlertDialogTitle>
<AlertDialogDescription>
This will remove {assignment.user.name || assignment.user.email} from
evaluating this project. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteAssignment.mutate({ id: assignment.id })
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
</div>
)
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Judge Assignments Yet</p>
<p className="text-sm text-muted-foreground">
Use the smart suggestions above or manually assign judges to
projects
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function AssignmentsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-64" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function AssignmentManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentManagementContent roundId={id} />
</Suspense>
)
}