Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Add mail/transfer/reshuffle/redistribute icons to each juror row in Members card - New redistributeJurorAssignments procedure: reassign all pending projects without dropping juror from group - New DROPOUT_REASSIGNED email template with project names, deadline, and dropped juror context - Update reassignDroppedJuror to send per-juror DROPOUT_REASSIGNED emails instead of generic BATCH_ASSIGNED - Transfer dialog now shows all candidates with "Already assigned" / "At cap" labels instead of hiding them - SQL script for prod DB insertion of new notification setting without seeding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
'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<Set<string>>(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<Record<string, string>>({})
|
|
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<string, number>()
|
|
for (const c of candidateData.candidates) {
|
|
simLoad.set(c.userId, c.currentLoad)
|
|
}
|
|
|
|
const overrides: Record<string, string> = {}
|
|
|
|
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<string, number>()
|
|
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 (
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
|
|
<DialogDescription>
|
|
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{step === 1 && (
|
|
<div className="space-y-3">
|
|
{loadingAssignments ? (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
|
</div>
|
|
) : jurorAssignments.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 pb-2 border-b">
|
|
<Checkbox
|
|
checked={allMovableSelected}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
|
|
} else {
|
|
setSelectedIds(new Set())
|
|
}
|
|
}}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
|
|
</div>
|
|
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
|
{jurorAssignments.map((a: any) => {
|
|
const movable = isMovable(a)
|
|
const status = a.evaluation?.status || 'No evaluation'
|
|
return (
|
|
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
|
|
<Checkbox
|
|
checked={selectedIds.has(a.id)}
|
|
disabled={!movable}
|
|
onCheckedChange={(checked) => {
|
|
const next = new Set(selectedIds)
|
|
if (checked) next.add(a.id)
|
|
else next.delete(a.id)
|
|
setSelectedIds(next)
|
|
}}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
|
|
</div>
|
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
{status}
|
|
</Badge>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
|
<Button
|
|
disabled={selectedIds.size === 0}
|
|
onClick={() => { setStep(2); setDestOverrides({}) }}
|
|
>
|
|
Next ({selectedIds.size} selected)
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<div className="space-y-3">
|
|
{loadingCandidates ? (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
|
</div>
|
|
) : !candidateData || candidateData.candidates.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-end">
|
|
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
|
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
|
Auto-assign
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
|
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
|
|
const currentDest = destOverrides[assignment.id] ||
|
|
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
|
|
return (
|
|
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm truncate">{assignment.projectTitle}</p>
|
|
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
|
|
</div>
|
|
<Select
|
|
value={currentDest}
|
|
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
|
>
|
|
<SelectTrigger className="w-[220px] h-8 text-xs">
|
|
<SelectValue placeholder="Select juror" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{candidateData.candidates.map((c) => {
|
|
const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
|
|
const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
|
|
return (
|
|
<SelectItem
|
|
key={c.userId}
|
|
value={c.userId}
|
|
disabled={!isEligible}
|
|
className={cn(!isEligible && 'opacity-50')}
|
|
>
|
|
<span>{c.name}</span>
|
|
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
|
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
|
{alreadyHas && <span className="text-amber-600 ml-1">Already assigned</span>}
|
|
{!isEligible && !alreadyHas && c.currentLoad >= c.cap && <span className="text-red-500 ml-1">At cap</span>}
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{transferPlan.length > 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
|
|
</p>
|
|
)}
|
|
|
|
{anyOverCap && (
|
|
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
|
|
<Checkbox
|
|
checked={forceOverCap}
|
|
onCheckedChange={(checked) => setForceOverCap(!!checked)}
|
|
/>
|
|
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
|
|
<Button
|
|
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
|
|
onClick={handleTransfer}
|
|
>
|
|
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
|
Transfer {transferPlan.length} project(s)
|
|
</Button>
|
|
</DialogFooter>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|