Auto-reassign projects when juror declares conflict of interest
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s

When a juror declares COI, the system now automatically:
- Finds an eligible replacement juror (not at capacity, no COI, not already assigned)
- Deletes the conflicted assignment and creates a new one
- Notifies the replacement juror and admins
- Load-balances by picking the juror with fewest current assignments

Also adds:
- "Reassign (COI)" action in assignment table dropdown with COI badge indicator
- Admin "Reassign to another juror" in COI review now triggers actual reassignment
- Per-juror notify button is now always visible (not just on hover)
- reassignCOI admin procedure for retroactive manual reassignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-19 18:30:01 +01:00
parent 1dcc7a5990
commit 0ff84686f0
3 changed files with 259 additions and 17 deletions

View File

@@ -2411,7 +2411,7 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
>
@@ -2671,6 +2671,17 @@ function IndividualAssignmentsTable({
onError: (err) => toast.error(err.message),
})
const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.evaluation.listCOIByStage.invalidate({ roundId })
toast.success(`Reassigned to ${data.newJurorName}`)
},
onError: (err) => toast.error(err.message),
})
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
@@ -2875,19 +2886,27 @@ function IndividualAssignmentsTable({
>
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
a.evaluation?.status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a.evaluation?.status === 'DRAFT'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
<div className="flex items-center gap-1">
{a.conflictOfInterest?.hasConflict ? (
<Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
COI
</Badge>
) : (
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
a.evaluation?.status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a.evaluation?.status === 'DRAFT'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
)}
>
{a.evaluation?.status || 'PENDING'}
</Badge>
)}
>
{a.evaluation?.status || 'PENDING'}
</Badge>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
@@ -2895,6 +2914,18 @@ function IndividualAssignmentsTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{a.conflictOfInterest?.hasConflict && (
<>
<DropdownMenuItem
onClick={() => reassignCOIMutation.mutate({ assignmentId: a.id })}
disabled={reassignCOIMutation.isPending}
>
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
Reassign (COI)
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{a.evaluation && (
<>
<DropdownMenuItem
@@ -3988,9 +4019,15 @@ function COIReviewSection({ roundId }: { roundId: string }) {
)
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
onSuccess: () => {
onSuccess: (data) => {
utils.evaluation.listCOIByStage.invalidate({ roundId })
toast.success('COI review updated')
utils.assignment.listByStage.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
if (data.reassignment) {
toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
} else {
toast.success('COI review updated')
}
},
onError: (err) => toast.error(err.message),
})