'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, Sparkles, Loader2, Plus, Trash2, RefreshCw, UserPlus, Cpu, Brain, 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 onToggle: (key: string) => void onSelectAll: () => void onApply: () => void isApplying: boolean }) { return (
0} onCheckedChange={onSelectAll} /> {selectedSuggestions.size} of {suggestions.length} selected
Juror Project Score Reasoning {suggestions.map((suggestion) => { const key = `${suggestion.userId}-${suggestion.projectId}` const isSelected = selectedSuggestions.has(key) return ( onToggle(key)} /> {suggestion.jurorName} {suggestion.projectTitle} = 60 ? 'default' : suggestion.score >= 40 ? 'secondary' : 'outline' } > {suggestion.score.toFixed(0)}
    {suggestion.reasoning.map((r, i) => (
  • {r}
  • ))}
) })}
) } interface PageProps { params: Promise<{ id: string }> } function AssignmentManagementContent({ roundId }: { roundId: string }) { const [selectedSuggestions, setSelectedSuggestions] = useState>(new Set()) const [manualOpen, setManualOpen] = useState(false) const [selectedJuror, setSelectedJuror] = useState('') const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false) const [projectSearch, setProjectSearch] = useState('') const [selectedProjects, setSelectedProjects] = useState>(new Set()) const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm') const [activeJobId, setActiveJobId] = useState(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 } if (!round) { return (

Round Not Found

) } 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) || {} return (
{/* Header */}
{round.program.name} / {round.name}

Manage Judge Assignments

{/* Manual Assignment Toggle */}
{/* Inline Manual Assignment Section */} {manualOpen && (
Manual Assignment
Select a jury member, then pick projects to assign
{/* Step 1: Juror Picker */}
No jurors found. {availableJurors?.map((juror) => { const maxAllowed = juror.maxAssignments ?? 10 const isFull = juror.currentAssignments >= maxAllowed return ( { setSelectedJuror(juror.id === selectedJuror ? '' : juror.id) setSelectedProjects(new Set()) setJurorPopoverOpen(false) }} disabled={isFull} className={isFull ? 'opacity-50' : ''} >

{juror.name || juror.email}

{juror.name && (

{juror.email}

)}
{juror.currentAssignments}/{maxAllowed} {isFull && ' Full'}
) })}
{/* Step 2: Project Multi-Select */}
{!selectedJuror ? (

Select a jury member first to see available projects

) : ( <>
setProjectSearch(e.target.value)} className="pl-9" />
{(() => { 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 ( <>
{ if (allUnassignedSelected) { setSelectedProjects(new Set()) } else { setSelectedProjects(new Set(unassignedToJuror.map(p => p.id))) } }} /> Select all unassigned ({unassignedToJuror.length})
{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 (
{ setSelectedProjects(prev => { const next = new Set(prev) if (next.has(project.id)) { next.delete(project.id) } else { next.add(project.id) } return next }) }} /> {project.title} {isAlreadyAssigned ? ( <> Assigned ) : isFullCoverage ? ( <> {assignmentCount}/{round.requiredReviews} ) : ( `${assignmentCount}/${round.requiredReviews} reviewers` )}
) })} {filtered.length === 0 && (
No projects match your search
)}
) })()} )}
{/* Assign Button */} {selectedJuror && selectedProjects.size > 0 && ( )}
)} {/* Stats */} {stats && (
Total Judge Assignments
{stats.totalAssignments}
Completed
{stats.completedAssignments}

{stats.completionPercentage}% complete

Projects Covered
{stats.projectsWithFullCoverage}/{stats.totalProjects}

{stats.coveragePercentage}% have {round.requiredReviews}+ reviews

Jury Members
{stats.juryMembersAssigned}

assigned to projects

)} {/* Coverage Progress */} {stats && ( Project Coverage {stats.projectsWithFullCoverage} of {stats.totalProjects} projects have at least {round.requiredReviews} reviewers assigned )} {/* Smart Suggestions with Tabs */} Smart Assignment Suggestions Get assignment recommendations using algorithmic matching or AI-powered analysis { setActiveTab(v as 'algorithm' | 'ai') setSelectedSuggestions(new Set()) }}>
Algorithm {algorithmicSuggestions && algorithmicSuggestions.length > 0 && ( {algorithmicSuggestions.length} )} AI Powered {aiSuggestions.length > 0 && ( {aiSuggestions.length} )} {isAIJobRunning && ( )} {/* Tab-specific actions */} {activeTab === 'algorithm' ? ( ) : (
{!isAIJobRunning && ( )}
)}
{/* Algorithm Tab Content */}
Algorithmic recommendations based on tag matching and workload balance
{loadingAlgorithmic ? (
) : algorithmicSuggestions && algorithmicSuggestions.length > 0 ? ( ) : (

All projects are covered!

No additional assignments are needed at this time

)}
{/* AI Tab Content */}
GPT-powered recommendations analyzing project descriptions and judge expertise
{/* AI Job Progress Indicator */} {isAIJobRunning && jobStatus && (

AI Assignment Analysis in Progress

Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches

Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed {aiJobProgressPercent}%
)} {isAIJobRunning ? null : loadingAI ? (
) : aiSuggestions.length > 0 ? ( ) : !hasStoredAISuggestions ? (

No AI analysis yet

Click "Start Analysis" to generate AI-powered suggestions

) : (

All projects are covered!

No additional assignments are needed at this time

)}
{/* Current Judge Assignments */} Current Judge Assignments View and manage existing project-to-judge assignments {Object.keys(assignmentsByProject).length > 0 ? (
{Object.entries(assignmentsByProject).map( ([projectId, { project, assignments: projectAssignments }]) => (

{project.title}

{projectAssignments.length} reviewer {projectAssignments.length !== 1 ? 's' : ''} {projectAssignments.length >= round.requiredReviews && ( Full coverage )}
{projectAssignments.map((assignment) => (
{assignment.user.name || assignment.user.email} {assignment.evaluation?.status === 'SUBMITTED' ? ( Submitted ) : assignment.evaluation?.status === 'DRAFT' ? ( In Progress ) : ( Pending )}
Remove Assignment? This will remove {assignment.user.name || assignment.user.email} from evaluating this project. This action cannot be undone. Cancel deleteAssignment.mutate({ id: assignment.id }) } className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Remove
))}
) )}
) : (

No Judge Assignments Yet

Use the smart suggestions above or manually assign judges to projects

)}
) } function AssignmentsSkeleton() { return (
{[1, 2, 3, 4].map((i) => ( ))}
) } export default function AssignmentManagementPage({ params }: PageProps) { const { id } = use(params) return ( }> ) }