Compare commits
2 Commits
5a609457c2
...
09cc49d920
| Author | SHA1 | Date | |
|---|---|---|---|
| 09cc49d920 | |||
| 351d8144d9 |
@@ -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] ||
|
||||||
@@ -2997,19 +3046,19 @@ function ScoreDistribution({ roundId }: { roundId: string }) {
|
|||||||
No evaluations submitted yet
|
No evaluations submitted yet
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-1 h-32">
|
<div className="flex gap-1 h-32">
|
||||||
{dist.globalDistribution.map((bucket) => {
|
{dist.globalDistribution.map((bucket) => {
|
||||||
const heightPct = (bucket.count / maxCount) * 100
|
const heightPct = (bucket.count / maxCount) * 100
|
||||||
return (
|
return (
|
||||||
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
|
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1 h-full">
|
||||||
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
|
||||||
<div className="w-full relative rounded-t" style={{ height: `${Math.max(heightPct, 2)}%` }}>
|
<div className="w-full flex-1 relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute inset-0 rounded-t transition-all',
|
'absolute inset-x-0 bottom-0 rounded-t transition-all',
|
||||||
bucket.score <= 3 ? 'bg-red-400' :
|
bucket.score <= 3 ? 'bg-red-400' :
|
||||||
bucket.score <= 6 ? 'bg-amber-400' :
|
bucket.score <= 6 ? 'bg-amber-400' :
|
||||||
'bg-emerald-400',
|
'bg-emerald-400',
|
||||||
)} />
|
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user