Fix score distribution chart and add auto-assign for transfer dialog
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s

- Fix bar chart CSS: percentage heights now resolve correctly with flex-1
  and absolute bottom-anchored bars
- Add Auto-assign button to transfer assignments dialog that distributes
  projects across eligible jurors balanced by load, preferring jurors who
  haven't completed all evaluations and are under their cap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 20:16:15 +01:00
parent 351d8144d9
commit 09cc49d920

View File

@@ -89,6 +89,7 @@ import {
History, History,
ChevronRight, ChevronRight,
ArrowRightLeft, ArrowRightLeft,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { import {
Command, Command,
@@ -2651,6 +2652,48 @@ function TransferAssignmentsDialog({
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({}) const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
const [forceOverCap, setForceOverCap] = useState(false) 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({ const transferMutation = trpc.assignment.transferAssignments.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId }) utils.assignment.listByStage.invalidate({ roundId })
@@ -2799,6 +2842,12 @@ function TransferAssignmentsDialog({
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p> <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"> <div className="space-y-2 max-h-[350px] overflow-y-auto">
{candidateData.assignments.filter((a) => a.movable).map((assignment) => { {candidateData.assignments.filter((a) => a.movable).map((assignment) => {
const currentDest = destOverrides[assignment.id] || const currentDest = destOverrides[assignment.id] ||