'use client' import { useState, useMemo } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Loader2, Sparkles } from 'lucide-react' export type TransferAssignmentsDialogProps = { roundId: string sourceJuror: { id: string; name: string } open: boolean onClose: () => void } export function TransferAssignmentsDialog({ roundId, sourceJuror, open, onClose, }: TransferAssignmentsDialogProps) { const utils = trpc.useUtils() const [step, setStep] = useState<1 | 2>(1) const [selectedIds, setSelectedIds] = useState>(new Set()) // Fetch source juror's assignments const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery( { roundId }, { enabled: open }, ) const jurorAssignments = useMemo(() => (sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id), [sourceAssignments, sourceJuror.id], ) // Fetch transfer candidates when in step 2 const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery( { roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] }, { enabled: step === 2 && selectedIds.size > 0 }, ) // Per-assignment destination overrides const [destOverrides, setDestOverrides] = useState>({}) const [forceOverCap, setForceOverCap] = useState(false) // Auto-assign: distribute assignments across eligible candidates balanced by load const handleAutoAssign = () => { if (!candidateData) return const movable = candidateData.assignments.filter((a) => a.movable) if (movable.length === 0) return // Simulate load starting from each candidate's current load const simLoad = new Map() for (const c of candidateData.candidates) { simLoad.set(c.userId, c.currentLoad) } const overrides: Record = {} for (const assignment of movable) { const eligible = candidateData.candidates .filter((c) => c.eligibleProjectIds.includes(assignment.projectId)) if (eligible.length === 0) continue // Sort: prefer not-all-completed, then under cap, then lowest simulated load const sorted = [...eligible].sort((a, b) => { // Prefer jurors who haven't completed all evaluations if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1 const loadA = simLoad.get(a.userId) ?? 0 const loadB = simLoad.get(b.userId) ?? 0 // Prefer jurors under their cap const overCapA = loadA >= a.cap ? 1 : 0 const overCapB = loadB >= b.cap ? 1 : 0 if (overCapA !== overCapB) return overCapA - overCapB // Then pick the least loaded return loadA - loadB }) const best = sorted[0] overrides[assignment.id] = best.userId simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1) } setDestOverrides(overrides) } const transferMutation = trpc.assignment.transferAssignments.useMutation({ onSuccess: (data) => { utils.assignment.listByStage.invalidate({ roundId }) utils.analytics.getJurorWorkload.invalidate({ roundId }) utils.roundAssignment.unassignedQueue.invalidate({ roundId }) utils.assignment.getReassignmentHistory.invalidate({ roundId }) const successCount = data.succeeded.length const failCount = data.failed.length if (failCount > 0) { toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`) } else { toast.success(`Transferred ${successCount} project(s) successfully.`) } onClose() }, onError: (err) => toast.error(err.message), }) // Build the transfer plan: for each selected assignment, determine destination const transferPlan = useMemo(() => { if (!candidateData) return [] const movable = candidateData.assignments.filter((a) => a.movable) return movable.map((assignment) => { const override = destOverrides[assignment.id] // Default: first eligible candidate const defaultDest = candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId) ) const destId = override || defaultDest?.userId || '' const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || '' return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName } }).filter((t) => t.destinationJurorId) }, [candidateData, destOverrides]) // Check if any destination is at or over cap const anyOverCap = useMemo(() => { if (!candidateData) return false const destCounts = new Map() for (const t of transferPlan) { destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1) } return candidateData.candidates.some((c) => { const extraLoad = destCounts.get(c.userId) ?? 0 return c.currentLoad + extraLoad > c.cap }) }, [candidateData, transferPlan]) const handleTransfer = () => { transferMutation.mutate({ roundId, sourceJurorId: sourceJuror.id, transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })), forceOverCap, }) } const isMovable = (a: any) => { const status = a.evaluation?.status return !status || status === 'NOT_STARTED' || status === 'DRAFT' } const movableAssignments = jurorAssignments.filter(isMovable) const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id)) return ( { if (!v) onClose() }}> Transfer Assignments from {sourceJuror.name} {step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'} {step === 1 && (
{loadingAssignments ? (
{[1, 2, 3].map((i) => )}
) : jurorAssignments.length === 0 ? (

No assignments found.

) : ( <>
{ if (checked) { setSelectedIds(new Set(movableAssignments.map((a: any) => a.id))) } else { setSelectedIds(new Set()) } }} /> Select all movable ({movableAssignments.length})
{jurorAssignments.map((a: any) => { const movable = isMovable(a) const status = a.evaluation?.status || 'No evaluation' return (
{ const next = new Set(selectedIds) if (checked) next.add(a.id) else next.delete(a.id) setSelectedIds(next) }} />

{a.project?.title || 'Unknown'}

{status}
) })}
)}
)} {step === 2 && (
{loadingCandidates ? (
{[1, 2, 3].map((i) => )}
) : !candidateData || candidateData.candidates.length === 0 ? (

No eligible candidates found.

) : ( <>
{candidateData.assignments.filter((a) => a.movable).map((assignment) => { const currentDest = destOverrides[assignment.id] || candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || '' return (

{assignment.projectTitle}

{assignment.evalStatus || 'No evaluation'}

) })}
{transferPlan.length > 0 && (

Transfer {transferPlan.length} project(s) from {sourceJuror.name}

)} {anyOverCap && (
setForceOverCap(!!checked)} /> Force over-cap: some destinations will exceed their assignment limit
)} )}
)}
) }