From 95d51e7de301559f626aaa52f60ec77c13a58238 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 23 Feb 2026 16:08:46 +0100 Subject: [PATCH] Add juror quick actions to Members section, redistribute button, dropout emails, and transfer duplicate detection - 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 --- .../insert-dropout-reassigned-setting.sql | 16 ++ prisma/seed-notification-settings.ts | 7 + .../(admin)/admin/rounds/[roundId]/page.tsx | 201 ++++++++++++++ .../transfer-assignments-dialog.tsx | 21 +- src/lib/email.ts | 79 ++++++ src/server/routers/assignment.ts | 258 +++++++++++++++++- src/server/services/in-app-notification.ts | 3 + 7 files changed, 570 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/insert-dropout-reassigned-setting.sql diff --git a/prisma/migrations/insert-dropout-reassigned-setting.sql b/prisma/migrations/insert-dropout-reassigned-setting.sql new file mode 100644 index 0000000..390ce73 --- /dev/null +++ b/prisma/migrations/insert-dropout-reassigned-setting.sql @@ -0,0 +1,16 @@ +-- Insert DROPOUT_REASSIGNED notification email setting into production DB +-- Run manually: psql -d mopc -f prisma/migrations/insert-dropout-reassigned-setting.sql +-- Safe to run multiple times (uses ON CONFLICT to skip if already exists) + +INSERT INTO "NotificationEmailSetting" ( + "id", "notificationType", "category", "label", "description", "sendEmail", "createdAt", "updatedAt" +) VALUES ( + gen_random_uuid()::text, + 'DROPOUT_REASSIGNED', + 'jury', + 'Juror Dropout Reassignment', + 'When projects are reassigned to you because a juror dropped out or became unavailable', + true, + NOW(), + NOW() +) ON CONFLICT ("notificationType") DO NOTHING; diff --git a/prisma/seed-notification-settings.ts b/prisma/seed-notification-settings.ts index deefc7f..5723d44 100644 --- a/prisma/seed-notification-settings.ts +++ b/prisma/seed-notification-settings.ts @@ -104,6 +104,13 @@ const NOTIFICATION_EMAIL_SETTINGS = [ description: 'When an admin manually reassigns a project to you', sendEmail: true, }, + { + notificationType: 'DROPOUT_REASSIGNED', + category: 'jury', + label: 'Juror Dropout Reassignment', + description: 'When projects are reassigned to you because a juror dropped out or became unavailable', + sendEmail: true, + }, { notificationType: 'ROUND_NOW_OPEN', category: 'jury', diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index d0ffe90..1021941 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -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, })} /> + + + + + +

Notify juror of assignments

+
+
+ + + + + +

Reassign all pending projects to other jurors

+
+
+ + + + + +

Transfer specific assignments to other jurors

+
+
+ + + + + +

Drop juror & reshuffle pending projects

+
+
+ +

Notify juror of assignments

+ + + + + + + +

Transfer assignments to other jurors

+
+
+ + + + + +

Drop juror & reshuffle pending projects

+
+