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

1179 lines
46 KiB
TypeScript

'use client'
import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
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 {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
ArrowLeft,
Users,
FileText,
CheckCircle2,
Clock,
AlertCircle,
Shuffle,
Loader2,
Plus,
Trash2,
RefreshCw,
UserPlus,
Calculator,
Workflow,
Search,
ChevronsUpDown,
Check,
X,
} from 'lucide-react'
import { toast } from 'sonner'
// Suggestion type for both algorithm and AI suggestions
interface Suggestion {
userId: string
jurorName: string
projectId: string
projectTitle: string
score: number
reasoning: string[]
}
// Reusable table component for displaying suggestions
function SuggestionsTable({
suggestions,
selectedSuggestions,
onToggle,
onSelectAll,
onApply,
isApplying,
}: {
suggestions: Suggestion[]
selectedSuggestions: Set<string>
onToggle: (key: string) => void
onSelectAll: () => void
onApply: () => void
isApplying: boolean
}) {
return (
<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 && suggestions.length > 0}
onCheckedChange={onSelectAll}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={onApply}
disabled={selectedSuggestions.size === 0 || isApplying}
>
{isApplying ? (
<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={() => onToggle(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>
)
}
interface PageProps {
params: Promise<{ id: string }>
}
function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const [manualOpen, setManualOpen] = useState(false)
const [selectedJuror, setSelectedJuror] = useState<string>('')
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
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()
// Always fetch latest AI job to check for existing results
const { data: latestJob, refetch: refetchLatestJob } = trpc.assignment.getLatestAIAssignmentJob.useQuery(
{ roundId }
)
// 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
// Check if there's a completed AI job with stored suggestions
const hasStoredAISuggestions = latestJob?.status === 'COMPLETED' && latestJob?.suggestionsCount > 0
// Algorithmic suggestions (always fetch for algorithm tab)
const { data: algorithmicSuggestions, isLoading: loadingAlgorithmic, refetch: refetchAlgorithmic } = trpc.assignment.getSuggestions.useQuery(
{ roundId },
{ enabled: !!round }
)
// AI-powered suggestions - fetch if there are stored results OR if AI tab is active
const { data: aiSuggestionsRaw, isLoading: loadingAI, refetch: refetchAI } = trpc.assignment.getAISuggestions.useQuery(
{ roundId, useAI: true },
{
enabled: !!round && (hasStoredAISuggestions || activeTab === 'ai') && !isAIJobRunning,
staleTime: Infinity, // Never consider stale (only refetch manually)
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
)
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
setActiveJobId(latestJob.id)
setActiveTab('ai') // Switch to AI tab if a job is running
}
}, [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 {
setActiveTab('ai') // Switch to AI tab when starting
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 active tab
const currentSuggestions = activeTab === 'ai' ? aiSuggestions : (algorithmicSuggestions ?? [])
const isLoadingCurrentSuggestions = activeTab === 'ai' ? (loadingAI || isAIJobRunning) : loadingAlgorithmic
// Get available jurors for manual assignment
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: manualOpen }
)
// Get projects in this round for manual assignment
const { data: roundProjects } = trpc.project.list.useQuery(
{ roundId, perPage: 500 },
{ enabled: manualOpen }
)
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 })
},
onError: (error) => {
toast.error(error.message || 'Failed to create assignment')
},
})
const [bulkAssigning, setBulkAssigning] = useState(false)
const handleBulkAssign = async () => {
if (!selectedJuror || selectedProjects.size === 0) {
toast.error('Please select a juror and at least one project')
return
}
setBulkAssigning(true)
let successCount = 0
let errorCount = 0
for (const projectId of selectedProjects) {
try {
await createAssignment.mutateAsync({
userId: selectedJuror,
projectId,
roundId,
})
successCount++
} catch {
errorCount++
}
}
setBulkAssigning(false)
setSelectedProjects(new Set())
if (successCount > 0) {
toast.success(`${successCount} assignment${successCount > 1 ? 's' : ''} created successfully`)
}
if (errorCount > 0) {
toast.error(`${errorCount} assignment${errorCount > 1 ? 's' : ''} failed`)
}
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ 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 (currentSuggestions) {
if (selectedSuggestions.size === currentSuggestions.length) {
setSelectedSuggestions(new Set())
} else {
setSelectedSuggestions(
new Set(currentSuggestions.map((s) => `${s.userId}-${s.projectId}`))
)
}
}
}
const handleApplySelected = async () => {
if (!currentSuggestions) return
const selected = currentSuggestions.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 Toggle */}
<Button
variant={manualOpen ? 'secondary' : 'default'}
onClick={() => {
setManualOpen(!manualOpen)
if (!manualOpen) {
setSelectedJuror('')
setSelectedProjects(new Set())
setProjectSearch('')
}
}}
>
{manualOpen ? (
<>
<X className="mr-2 h-4 w-4" />
Close
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
Manual Assignment
</>
)}
</Button>
</div>
{/* Inline Manual Assignment Section */}
{manualOpen && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Manual Assignment
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setManualOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<CardDescription>
Select a jury member, then pick projects to assign
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* Step 1: Juror Picker */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Step 1: Select Jury Member</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedJuror && availableJurors ? (
(() => {
const juror = availableJurors.find(j => j.id === selectedJuror)
if (!juror) return 'Select a jury member...'
const maxAllowed = juror.maxAssignments ?? 10
return (
<span className="flex items-center gap-2">
<span>{juror.name || juror.email}</span>
<Badge variant="secondary" className="text-xs">
{juror.currentAssignments}/{maxAllowed} assigned
</Badge>
</span>
)
})()
) : (
'Select a jury member...'
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search jurors..." />
<CommandList>
<CommandEmpty>No jurors found.</CommandEmpty>
<CommandGroup>
{availableJurors?.map((juror) => {
const maxAllowed = juror.maxAssignments ?? 10
const isFull = juror.currentAssignments >= maxAllowed
return (
<CommandItem
key={juror.id}
value={`${juror.name || ''} ${juror.email}`}
onSelect={() => {
setSelectedJuror(juror.id === selectedJuror ? '' : juror.id)
setSelectedProjects(new Set())
setJurorPopoverOpen(false)
}}
disabled={isFull}
className={isFull ? 'opacity-50' : ''}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJuror === juror.id ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="flex items-center justify-between w-full">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{juror.name || juror.email}</p>
{juror.name && (
<p className="text-xs text-muted-foreground truncate">{juror.email}</p>
)}
</div>
<Badge variant={isFull ? 'destructive' : 'secondary'} className="text-xs ml-2 shrink-0">
{juror.currentAssignments}/{maxAllowed}
{isFull && ' Full'}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Step 2: Project Multi-Select */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Step 2: Select Projects</Label>
{!selectedJuror ? (
<p className="text-sm text-muted-foreground py-4 text-center">
Select a jury member first to see available projects
</p>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9"
/>
</div>
{(() => {
const projects = roundProjects?.projects ?? []
const filtered = projects.filter(p =>
p.title.toLowerCase().includes(projectSearch.toLowerCase())
)
const unassignedToJuror = filtered.filter(p =>
!assignments?.some(a => a.userId === selectedJuror && a.projectId === p.id)
)
const allUnassignedSelected = unassignedToJuror.length > 0 &&
unassignedToJuror.every(p => selectedProjects.has(p.id))
return (
<>
<div className="flex items-center gap-2 py-1">
<Checkbox
checked={allUnassignedSelected}
onCheckedChange={() => {
if (allUnassignedSelected) {
setSelectedProjects(new Set())
} else {
setSelectedProjects(new Set(unassignedToJuror.map(p => p.id)))
}
}}
/>
<span className="text-sm text-muted-foreground">
Select all unassigned ({unassignedToJuror.length})
</span>
</div>
<ScrollArea className="h-[280px] rounded-lg border">
<div className="divide-y">
{filtered.map((project) => {
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
const isAlreadyAssigned = assignments?.some(
a => a.userId === selectedJuror && a.projectId === project.id
)
const isFullCoverage = assignmentCount >= round.requiredReviews
const isChecked = selectedProjects.has(project.id)
return (
<div
key={project.id}
className={cn(
'flex items-center gap-3 px-3 py-2.5 transition-colors',
isAlreadyAssigned ? 'opacity-40 bg-muted/30' : 'hover:bg-muted/50',
isChecked && !isAlreadyAssigned && 'bg-blue-50/50 dark:bg-blue-950/20',
)}
>
<Checkbox
checked={isChecked}
disabled={!!isAlreadyAssigned}
onCheckedChange={() => {
setSelectedProjects(prev => {
const next = new Set(prev)
if (next.has(project.id)) {
next.delete(project.id)
} else {
next.add(project.id)
}
return next
})
}}
/>
<span className={cn(
'flex-1 text-sm truncate',
isAlreadyAssigned && 'line-through'
)}>
{project.title}
</span>
<Badge
variant={isFullCoverage ? 'default' : 'outline'}
className={cn(
'text-xs shrink-0',
isFullCoverage && 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400 border-0',
isAlreadyAssigned && 'bg-muted text-muted-foreground border-0'
)}
>
{isAlreadyAssigned ? (
<>
<Check className="mr-1 h-3 w-3" />
Assigned
</>
) : isFullCoverage ? (
<>
<CheckCircle2 className="mr-1 h-3 w-3" />
{assignmentCount}/{round.requiredReviews}
</>
) : (
`${assignmentCount}/${round.requiredReviews} reviewers`
)}
</Badge>
</div>
)
})}
{filtered.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No projects match your search
</div>
)}
</div>
</ScrollArea>
</>
)
})()}
</>
)}
</div>
{/* Assign Button */}
{selectedJuror && selectedProjects.size > 0 && (
<Button
onClick={handleBulkAssign}
disabled={bulkAssigning}
className="w-full"
size="lg"
>
{bulkAssigning ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Assign {selectedProjects.size} Project{selectedProjects.size > 1 ? 's' : ''}
{availableJurors && (() => {
const juror = availableJurors.find(j => j.id === selectedJuror)
return juror ? ` to ${juror.name || juror.email}` : ''
})()}
</Button>
)}
</CardContent>
</Card>
)}
{/* 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 with Tabs */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Shuffle className="h-5 w-5 text-amber-500" />
Smart Assignment Suggestions
</CardTitle>
<CardDescription>
Get assignment recommendations using algorithmic matching or AI-powered analysis
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={(v) => {
setActiveTab(v as 'algorithm' | 'ai')
setSelectedSuggestions(new Set())
}}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="algorithm" className="gap-2">
<Calculator className="h-4 w-4" />
Algorithm
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{algorithmicSuggestions.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
<Workflow className="h-4 w-4" />
AI Powered
{aiSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{aiSuggestions.length}
</Badge>
)}
{isAIJobRunning && (
<Loader2 className="h-3 w-3 animate-spin ml-1" />
)}
</TabsTrigger>
</TabsList>
{/* Tab-specific actions */}
{activeTab === 'algorithm' ? (
<Button
variant="outline"
size="sm"
onClick={() => refetchAlgorithmic()}
disabled={loadingAlgorithmic}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loadingAlgorithmic ? 'animate-spin' : ''}`} />
Refresh
</Button>
) : (
<div className="flex items-center gap-2">
{!isAIJobRunning && (
<Button
variant="outline"
size="sm"
onClick={handleStartAIJob}
disabled={startAIJob.isPending || !isAIAvailable}
title={!isAIAvailable ? 'OpenAI API key not configured' : hasStoredAISuggestions ? 'Run AI analysis again' : 'Start AI analysis'}
>
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{hasStoredAISuggestions ? 'Re-analyze' : 'Start Analysis'}
</Button>
)}
</div>
)}
</div>
{/* Algorithm Tab Content */}
<TabsContent value="algorithm" className="mt-0">
<div className="text-sm text-muted-foreground mb-4">
Algorithmic recommendations based on tag matching and workload balance
</div>
{loadingAlgorithmic ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : algorithmicSuggestions && algorithmicSuggestions.length > 0 ? (
<SuggestionsTable
suggestions={algorithmicSuggestions}
selectedSuggestions={selectedSuggestions}
onToggle={handleToggleSuggestion}
onSelectAll={handleSelectAllSuggestions}
onApply={handleApplySelected}
isApplying={applySuggestions.isPending}
/>
) : (
<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>
)}
</TabsContent>
{/* AI Tab Content */}
<TabsContent value="ai" className="mt-0">
<div className="text-sm text-muted-foreground mb-4">
GPT-powered recommendations analyzing project descriptions and judge expertise
</div>
{/* 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>
)}
{isAIJobRunning ? null : loadingAI ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : aiSuggestions.length > 0 ? (
<SuggestionsTable
suggestions={aiSuggestions}
selectedSuggestions={selectedSuggestions}
onToggle={handleToggleSuggestion}
onSelectAll={handleSelectAllSuggestions}
onApply={handleApplySelected}
isApplying={applySuggestions.isPending}
/>
) : !hasStoredAISuggestions ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Workflow className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No AI analysis yet</p>
<p className="text-sm text-muted-foreground mb-4">
Click &quot;Start Analysis&quot; to generate AI-powered suggestions
</p>
<Button
onClick={handleStartAIJob}
disabled={startAIJob.isPending || !isAIAvailable}
>
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Workflow className="mr-2 h-4 w-4" />
)}
Start AI Analysis
</Button>
</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>
)}
</TabsContent>
</Tabs>
</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>
)
}