Add juror quick actions to Members section, redistribute button, dropout emails, and transfer duplicate detection
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>
This commit is contained in:
2026-02-23 16:08:46 +01:00
parent 49e9405e01
commit 95d51e7de3
7 changed files with 570 additions and 15 deletions

View File

@@ -50,6 +50,7 @@ import {
} from '@/components/ui/select'
import {
ArrowLeft,
ArrowRightLeft,
Save,
Loader2,
ChevronDown,
@@ -64,6 +65,8 @@ import {
Settings,
Zap,
Shield,
Mail,
Shuffle,
UserPlus,
CheckCircle2,
AlertTriangle,
@@ -99,6 +102,7 @@ import {
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table'
import { TransferAssignmentsDialog } from '@/components/admin/assignment/transfer-assignments-dialog'
import { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history'
import { ScoreDistribution } from '@/components/admin/round/score-distribution'
import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
@@ -355,6 +359,46 @@ export default function RoundDetailPage() {
onError: (err) => toast.error(err.message),
})
// Jury member quick actions (same as in JuryProgressTable)
const [memberTransferJuror, setMemberTransferJuror] = useState<{ id: string; name: string } | null>(null)
const notifyMemberMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
onSuccess: (data) => {
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
},
onError: (err) => toast.error(err.message),
})
const redistributeMemberMutation = trpc.assignment.redistributeJurorAssignments.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
if (data.failedCount > 0) {
toast.warning(`Reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`)
} else {
toast.success(`Reassigned ${data.movedCount} project(s) to other jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
const reshuffleMemberMutation = trpc.assignment.reassignDroppedJuror.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
if (data.failedCount > 0) {
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`)
} else {
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
@@ -1655,6 +1699,93 @@ export default function RoundDetailPage() {
maxAssignmentsOverride: val,
})}
/>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={notifyMemberMutation.isPending}
onClick={() => notifyMemberMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMemberMutation.isPending && notifyMemberMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={redistributeMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Reassign all pending projects from ${member.user.name || member.user.email} to other available jurors? The juror will remain in the group but lose unsubmitted assignments.`
)
if (!ok) return
redistributeMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{redistributeMemberMutation.isPending && redistributeMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Shuffle className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Reassign all pending projects to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setMemberTransferJuror({ id: member.userId, name: member.user.name || member.user.email })}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer specific assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={reshuffleMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${member.user.name || member.user.email} from this jury pool and reassign all their unsubmitted projects to other jurors? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{reshuffleMemberMutation.isPending && reshuffleMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror & reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
@@ -1911,6 +2042,67 @@ export default function RoundDetailPage() {
maxAssignmentsOverride: val,
})}
/>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
disabled={notifyMemberMutation.isPending}
onClick={() => notifyMemberMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMemberMutation.isPending && notifyMemberMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setMemberTransferJuror({ id: member.userId, name: member.user.name || member.user.email })}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={reshuffleMemberMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${member.user.name || member.user.email} from this jury pool and reassign all their unsubmitted projects to other jurors? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMemberMutation.mutate({ roundId, jurorId: member.userId })
}}
>
{reshuffleMemberMutation.isPending && reshuffleMemberMutation.variables?.jurorId === member.userId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror & reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
@@ -2421,6 +2613,15 @@ export default function RoundDetailPage() {
</div>
</div>
)}
{memberTransferJuror && (
<TransferAssignmentsDialog
roundId={roundId}
sourceJuror={memberTransferJuror}
open={!!memberTransferJuror}
onClose={() => setMemberTransferJuror(null)}
/>
)}
</div>
)
}