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
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:
16
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
16
prisma/migrations/insert-dropout-reassigned-setting.sql
Normal file
@@ -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;
|
||||||
@@ -104,6 +104,13 @@ const NOTIFICATION_EMAIL_SETTINGS = [
|
|||||||
description: 'When an admin manually reassigns a project to you',
|
description: 'When an admin manually reassigns a project to you',
|
||||||
sendEmail: true,
|
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',
|
notificationType: 'ROUND_NOW_OPEN',
|
||||||
category: 'jury',
|
category: 'jury',
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -64,6 +65,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Zap,
|
Zap,
|
||||||
Shield,
|
Shield,
|
||||||
|
Mail,
|
||||||
|
Shuffle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -99,6 +102,7 @@ import {
|
|||||||
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
|
||||||
import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
|
import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
|
||||||
import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table'
|
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 { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history'
|
||||||
import { ScoreDistribution } from '@/components/admin/round/score-distribution'
|
import { ScoreDistribution } from '@/components/admin/round/score-distribution'
|
||||||
import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
|
import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
|
||||||
@@ -355,6 +359,46 @@ export default function RoundDetailPage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
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({
|
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
@@ -1655,6 +1699,93 @@ export default function RoundDetailPage() {
|
|||||||
maxAssignmentsOverride: val,
|
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>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -1911,6 +2042,67 @@ export default function RoundDetailPage() {
|
|||||||
maxAssignmentsOverride: val,
|
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>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -2421,6 +2613,15 @@ export default function RoundDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{memberTransferJuror && (
|
||||||
|
<TransferAssignmentsDialog
|
||||||
|
roundId={roundId}
|
||||||
|
sourceJuror={memberTransferJuror}
|
||||||
|
open={!!memberTransferJuror}
|
||||||
|
onClose={() => setMemberTransferJuror(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,19 +273,28 @@ export function TransferAssignmentsDialog({
|
|||||||
value={currentDest}
|
value={currentDest}
|
||||||
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px] h-8 text-xs">
|
<SelectTrigger className="w-[220px] h-8 text-xs">
|
||||||
<SelectValue placeholder="Select juror" />
|
<SelectValue placeholder="Select juror" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{candidateData.candidates
|
{candidateData.candidates.map((c) => {
|
||||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
|
||||||
.map((c) => (
|
const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
|
||||||
<SelectItem key={c.userId} value={c.userId}>
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={c.userId}
|
||||||
|
value={c.userId}
|
||||||
|
disabled={!isEligible}
|
||||||
|
className={cn(!isEligible && 'opacity-50')}
|
||||||
|
>
|
||||||
<span>{c.name}</span>
|
<span>{c.name}</span>
|
||||||
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
||||||
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
||||||
|
{alreadyHas && <span className="text-amber-600 ml-1">Already assigned</span>}
|
||||||
|
{!isEligible && !alreadyHas && c.currentLoad >= c.cap && <span className="text-red-500 ml-1">At cap</span>}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1077,6 +1077,76 @@ Together for a healthier ocean.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "Dropout Reassignment" email template (for jury)
|
||||||
|
* Sent when a juror drops out and their projects are redistributed.
|
||||||
|
*/
|
||||||
|
function getDropoutReassignedTemplate(
|
||||||
|
name: string,
|
||||||
|
projectNames: string[],
|
||||||
|
roundName: string,
|
||||||
|
droppedJurorName: string,
|
||||||
|
deadline?: string,
|
||||||
|
assignmentsUrl?: string
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
const count = projectNames.length
|
||||||
|
const isSingle = count === 1
|
||||||
|
|
||||||
|
const projectList = projectNames.map((p) => `
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 8px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fef3c7; border-left: 4px solid #f59e0b; border-radius: 0 8px 8px 0; padding: 14px 20px;">
|
||||||
|
<p style="color: ${BRAND.darkBlue}; margin: 0; font-size: 16px; font-weight: 700;">${p}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
const deadlineBox = deadline ? `
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fef2f2; border-left: 4px solid ${BRAND.red}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #991b1b; margin: 0 0 4px 0; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;">Deadline</p>
|
||||||
|
<p style="color: #7f1d1d; margin: 0; font-size: 16px; font-weight: 700;">${deadline}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
${paragraph(`Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been <strong>reassigned to you</strong> for evaluation in <strong style="color: ${BRAND.darkBlue};">${roundName}</strong>.`)}
|
||||||
|
${projectList}
|
||||||
|
${deadlineBox}
|
||||||
|
${paragraph(`${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.`)}
|
||||||
|
${assignmentsUrl ? ctaButton(assignmentsUrl, 'View Assignments') : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
const projectListText = projectNames.map((p) => ` - ${p}`).join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Project${isSingle ? '' : 's'} Reassigned to You (Juror Unavailable) - ${roundName}`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `
|
||||||
|
${greeting}
|
||||||
|
|
||||||
|
Due to a juror becoming unavailable, ${isSingle ? 'a project has' : `${count} projects have`} been reassigned to you for evaluation in ${roundName}.
|
||||||
|
|
||||||
|
${isSingle ? `Project: ${projectNames[0]}` : `Projects:\n${projectListText}`}
|
||||||
|
${deadline ? `Deadline: ${deadline}` : ''}
|
||||||
|
|
||||||
|
${isSingle ? 'This project was' : 'These projects were'} previously assigned to ${droppedJurorName}, who is no longer available. Please review the project material${isSingle ? '' : 's'} and submit your evaluation${isSingle ? '' : 's'} before the deadline.
|
||||||
|
|
||||||
|
${assignmentsUrl ? `View assignments: ${assignmentsUrl}` : ''}
|
||||||
|
|
||||||
|
---
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
Together for a healthier ocean.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate "Batch Assigned" email template (for jury)
|
* Generate "Batch Assigned" email template (for jury)
|
||||||
*/
|
*/
|
||||||
@@ -1677,6 +1747,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
ctx.metadata?.deadline as string | undefined,
|
ctx.metadata?.deadline as string | undefined,
|
||||||
ctx.linkUrl
|
ctx.linkUrl
|
||||||
),
|
),
|
||||||
|
DROPOUT_REASSIGNED: (ctx) =>
|
||||||
|
getDropoutReassignedTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectNames as string[]) || [(ctx.metadata?.projectName as string) || 'Project'],
|
||||||
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
|
(ctx.metadata?.droppedJurorName as string) || 'a fellow juror',
|
||||||
|
ctx.metadata?.deadline as string | undefined,
|
||||||
|
ctx.linkUrl
|
||||||
|
),
|
||||||
BATCH_ASSIGNED: (ctx) =>
|
BATCH_ASSIGNED: (ctx) =>
|
||||||
getBatchAssignedTemplate(
|
getBatchAssignedTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
|
|||||||
@@ -476,17 +476,39 @@ async function reassignDroppedJurorAssignments(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (actualMoves.length > 0) {
|
if (actualMoves.length > 0) {
|
||||||
await createBulkNotifications({
|
// Build per-juror project name lists for proper emails
|
||||||
userIds: Object.keys(reassignedTo),
|
const destProjectNames: Record<string, string[]> = {}
|
||||||
type: NotificationTypes.BATCH_ASSIGNED,
|
for (const move of actualMoves) {
|
||||||
title: 'Additional Projects Assigned',
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||||
message: `You have received additional project assignments due to a jury reassignment in ${round.name}.`,
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||||
linkUrl: `/jury/competitions`,
|
}
|
||||||
linkLabel: 'View Assignments',
|
|
||||||
metadata: { roundId: round.id, reason: 'juror_drop_reshuffle' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const droppedName = droppedJuror.name || droppedJuror.email
|
const droppedName = droppedJuror.name || droppedJuror.email
|
||||||
|
|
||||||
|
// Fetch round deadline for email
|
||||||
|
const roundFull = await prisma.round.findUnique({
|
||||||
|
where: { id: params.roundId },
|
||||||
|
select: { windowCloseAt: true },
|
||||||
|
})
|
||||||
|
const deadline = roundFull?.windowCloseAt
|
||||||
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(roundFull.windowCloseAt)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||||
|
const count = projectNames.length
|
||||||
|
await createNotification({
|
||||||
|
userId: jurorId,
|
||||||
|
type: NotificationTypes.DROPOUT_REASSIGNED,
|
||||||
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||||
|
message: count === 1
|
||||||
|
? `The project "${projectNames[0]}" has been reassigned to you because ${droppedName} is no longer available in ${round.name}.`
|
||||||
|
: `${count} projects have been reassigned to you because ${droppedName} is no longer available in ${round.name}: ${projectNames.join(', ')}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { roundId: round.id, roundName: round.name, projectNames, droppedJurorName: droppedName, deadline, reason: 'juror_drop_reshuffle' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const topReceivers = Object.entries(reassignedTo)
|
const topReceivers = Object.entries(reassignedTo)
|
||||||
.map(([jurorId, count]) => {
|
.map(([jurorId, count]) => {
|
||||||
const juror = candidateMeta.get(jurorId)
|
const juror = candidateMeta.get(jurorId)
|
||||||
@@ -2116,6 +2138,218 @@ export const assignmentRouter = router({
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redistribute all movable assignments from a juror to other jurors (without dropping them from the group).
|
||||||
|
* Uses the same greedy algorithm as reassignDroppedJuror but keeps the juror in the jury group.
|
||||||
|
* Prefers jurors who haven't finished all evaluations; as last resort uses completed jurors.
|
||||||
|
*/
|
||||||
|
redistributeJurorAssignments: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string(), jurorId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { id: true, name: true, configJson: true, juryGroupId: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceJuror = await ctx.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: input.jurorId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = (round.configJson ?? {}) as Record<string, unknown>
|
||||||
|
const fallbackCap =
|
||||||
|
(config.maxLoadPerJuror as number) ??
|
||||||
|
(config.maxAssignmentsPerJuror as number) ??
|
||||||
|
20
|
||||||
|
|
||||||
|
const assignmentsToMove = await ctx.prisma.assignment.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: input.roundId,
|
||||||
|
userId: input.jurorId,
|
||||||
|
OR: [
|
||||||
|
{ evaluation: null },
|
||||||
|
{ evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true, projectId: true, juryGroupId: true, isRequired: true,
|
||||||
|
project: { select: { title: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (assignmentsToMove.length === 0) {
|
||||||
|
return { movedCount: 0, failedCount: 0, failedProjects: [] as string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build candidate pool
|
||||||
|
let candidateJurors: { id: string; name: string | null; email: string; maxAssignments: number | null }[]
|
||||||
|
if (round.juryGroupId) {
|
||||||
|
const members = await ctx.prisma.juryGroupMember.findMany({
|
||||||
|
where: { juryGroupId: round.juryGroupId },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true, maxAssignments: true, status: true } } },
|
||||||
|
})
|
||||||
|
candidateJurors = members.filter((m) => m.user.status === 'ACTIVE' && m.user.id !== input.jurorId).map((m) => m.user)
|
||||||
|
} else {
|
||||||
|
const roundJurorIds = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
})
|
||||||
|
const ids = roundJurorIds.map((a) => a.userId).filter((id) => id !== input.jurorId)
|
||||||
|
candidateJurors = ids.length > 0
|
||||||
|
? await ctx.prisma.user.findMany({
|
||||||
|
where: { id: { in: ids }, role: 'JURY_MEMBER', status: 'ACTIVE' },
|
||||||
|
select: { id: true, name: true, email: true, maxAssignments: true },
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateJurors.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active replacement jurors available' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateIds = candidateJurors.map((j) => j.id)
|
||||||
|
|
||||||
|
const existingAssignments = await ctx.prisma.assignment.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const alreadyAssigned = new Set(existingAssignments.map((a) => `${a.userId}:${a.projectId}`))
|
||||||
|
const currentLoads = new Map<string, number>()
|
||||||
|
for (const a of existingAssignments) currentLoads.set(a.userId, (currentLoads.get(a.userId) ?? 0) + 1)
|
||||||
|
|
||||||
|
const coiRecords = await ctx.prisma.conflictOfInterest.findMany({
|
||||||
|
where: { roundId: input.roundId, hasConflict: true, userId: { in: candidateIds } },
|
||||||
|
select: { userId: true, projectId: true },
|
||||||
|
})
|
||||||
|
const coiPairs = new Set(coiRecords.map((c) => `${c.userId}:${c.projectId}`))
|
||||||
|
|
||||||
|
// Completed eval counts for "prefer not-finished" logic
|
||||||
|
const completedEvals = await ctx.prisma.evaluation.findMany({
|
||||||
|
where: { assignment: { roundId: input.roundId, userId: { in: candidateIds } }, status: 'SUBMITTED' },
|
||||||
|
select: { assignment: { select: { userId: true } } },
|
||||||
|
})
|
||||||
|
const completedCounts = new Map<string, number>()
|
||||||
|
for (const e of completedEvals) completedCounts.set(e.assignment.userId, (completedCounts.get(e.assignment.userId) ?? 0) + 1)
|
||||||
|
|
||||||
|
const caps = new Map<string, number>()
|
||||||
|
for (const j of candidateJurors) caps.set(j.id, j.maxAssignments ?? fallbackCap)
|
||||||
|
|
||||||
|
const plannedMoves: { assignmentId: string; projectId: string; projectTitle: string; newJurorId: string; juryGroupId: string | null; isRequired: boolean }[] = []
|
||||||
|
const failedProjects: string[] = []
|
||||||
|
|
||||||
|
for (const assignment of assignmentsToMove) {
|
||||||
|
// First pass: prefer jurors who haven't completed all evals
|
||||||
|
let eligible = candidateIds
|
||||||
|
.filter((jid) => !alreadyAssigned.has(`${jid}:${assignment.projectId}`))
|
||||||
|
.filter((jid) => !coiPairs.has(`${jid}:${assignment.projectId}`))
|
||||||
|
.filter((jid) => (currentLoads.get(jid) ?? 0) < (caps.get(jid) ?? fallbackCap))
|
||||||
|
|
||||||
|
// Sort: prefer not-all-completed, then lowest load
|
||||||
|
eligible.sort((a, b) => {
|
||||||
|
const loadA = currentLoads.get(a) ?? 0
|
||||||
|
const loadB = currentLoads.get(b) ?? 0
|
||||||
|
const compA = completedCounts.get(a) ?? 0
|
||||||
|
const compB = completedCounts.get(b) ?? 0
|
||||||
|
const doneA = loadA > 0 && compA === loadA ? 1 : 0
|
||||||
|
const doneB = loadB > 0 && compB === loadB ? 1 : 0
|
||||||
|
if (doneA !== doneB) return doneA - doneB
|
||||||
|
return loadA - loadB
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
failedProjects.push(assignment.project.title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedId = eligible[0]
|
||||||
|
plannedMoves.push({
|
||||||
|
assignmentId: assignment.id, projectId: assignment.projectId,
|
||||||
|
projectTitle: assignment.project.title, newJurorId: selectedId,
|
||||||
|
juryGroupId: assignment.juryGroupId ?? round.juryGroupId, isRequired: assignment.isRequired,
|
||||||
|
})
|
||||||
|
alreadyAssigned.add(`${selectedId}:${assignment.projectId}`)
|
||||||
|
currentLoads.set(selectedId, (currentLoads.get(selectedId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute in transaction
|
||||||
|
const actualMoves: typeof plannedMoves = []
|
||||||
|
if (plannedMoves.length > 0) {
|
||||||
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
for (const move of plannedMoves) {
|
||||||
|
const deleted = await tx.assignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: move.assignmentId, userId: input.jurorId,
|
||||||
|
OR: [{ evaluation: null }, { evaluation: { status: { in: [...MOVABLE_EVAL_STATUSES] } } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (deleted.count === 0) { failedProjects.push(move.projectTitle); continue }
|
||||||
|
await tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
roundId: input.roundId, projectId: move.projectId, userId: move.newJurorId,
|
||||||
|
juryGroupId: move.juryGroupId ?? undefined, isRequired: move.isRequired,
|
||||||
|
method: 'MANUAL', createdBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
actualMoves.push(move)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send MANUAL_REASSIGNED emails per destination juror
|
||||||
|
if (actualMoves.length > 0) {
|
||||||
|
const destProjectNames: Record<string, string[]> = {}
|
||||||
|
for (const move of actualMoves) {
|
||||||
|
if (!destProjectNames[move.newJurorId]) destProjectNames[move.newJurorId] = []
|
||||||
|
destProjectNames[move.newJurorId].push(move.projectTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = round.windowCloseAt
|
||||||
|
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'full', timeStyle: 'short', timeZone: 'Europe/Paris' }).format(round.windowCloseAt)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
for (const [jurorId, projectNames] of Object.entries(destProjectNames)) {
|
||||||
|
const count = projectNames.length
|
||||||
|
await createNotification({
|
||||||
|
userId: jurorId,
|
||||||
|
type: NotificationTypes.MANUAL_REASSIGNED,
|
||||||
|
title: count === 1 ? 'Project Reassigned to You' : `${count} Projects Reassigned to You`,
|
||||||
|
message: count === 1
|
||||||
|
? `The project "${projectNames[0]}" has been reassigned to you for evaluation in ${round.name}.`
|
||||||
|
: `${count} projects have been reassigned to you for evaluation in ${round.name}: ${projectNames.join(', ')}.`,
|
||||||
|
linkUrl: `/jury/competitions`,
|
||||||
|
linkLabel: 'View Assignments',
|
||||||
|
metadata: { roundId: round.id, roundName: round.name, projectNames, deadline, reason: 'admin_redistribute' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceName = sourceJuror.name || sourceJuror.email
|
||||||
|
const candidateMeta = new Map(candidateJurors.map((j) => [j.id, j]))
|
||||||
|
const topReceivers = Object.entries(destProjectNames)
|
||||||
|
.map(([jid, ps]) => { const j = candidateMeta.get(jid); return `${j?.name || j?.email || jid} (${ps.length})` })
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
await notifyAdmins({
|
||||||
|
type: NotificationTypes.EVALUATION_MILESTONE,
|
||||||
|
title: 'Assignment Redistribution',
|
||||||
|
message: `Redistributed ${actualMoves.length} project(s) from ${sourceName} to: ${topReceivers}.${failedProjects.length > 0 ? ` ${failedProjects.length} could not be reassigned.` : ''}`,
|
||||||
|
linkUrl: `/admin/rounds/${round.id}`,
|
||||||
|
linkLabel: 'View Round',
|
||||||
|
metadata: { roundId: round.id, sourceJurorId: input.jurorId, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma, userId: ctx.user.id, action: 'ASSIGNMENT_REDISTRIBUTE',
|
||||||
|
entityType: 'Round', entityId: round.id,
|
||||||
|
detailsJson: { sourceJurorId: input.jurorId, sourceName, movedCount: actualMoves.length, failedCount: failedProjects.length },
|
||||||
|
ipAddress: ctx.ip, userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { movedCount: actualMoves.length, failedCount: failedProjects.length, failedProjects }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get transfer candidates: which of the source juror's assignments can be moved,
|
* Get transfer candidates: which of the source juror's assignments can be moved,
|
||||||
* and which other jurors are eligible to receive them.
|
* and which other jurors are eligible to receive them.
|
||||||
@@ -2253,6 +2487,11 @@ export const assignmentRouter = router({
|
|||||||
load < cap
|
load < cap
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Track which movable projects this candidate already has assigned
|
||||||
|
const alreadyAssignedProjectIds = movableProjectIds.filter((pid) =>
|
||||||
|
alreadyAssigned.has(`${j.id}:${pid}`)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: j.id,
|
userId: j.id,
|
||||||
name: j.name || j.email,
|
name: j.name || j.email,
|
||||||
@@ -2261,6 +2500,7 @@ export const assignmentRouter = router({
|
|||||||
cap,
|
cap,
|
||||||
allCompleted,
|
allCompleted,
|
||||||
eligibleProjectIds,
|
eligibleProjectIds,
|
||||||
|
alreadyAssignedProjectIds,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const NotificationTypes = {
|
|||||||
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
|
||||||
COI_REASSIGNED: 'COI_REASSIGNED',
|
COI_REASSIGNED: 'COI_REASSIGNED',
|
||||||
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
|
MANUAL_REASSIGNED: 'MANUAL_REASSIGNED',
|
||||||
|
DROPOUT_REASSIGNED: 'DROPOUT_REASSIGNED',
|
||||||
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
|
||||||
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
PROJECT_UPDATED: 'PROJECT_UPDATED',
|
||||||
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
|
||||||
@@ -104,6 +105,7 @@ export const NotificationIcons: Record<string, string> = {
|
|||||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
|
||||||
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
|
[NotificationTypes.COI_REASSIGNED]: 'RefreshCw',
|
||||||
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
|
[NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft',
|
||||||
|
[NotificationTypes.DROPOUT_REASSIGNED]: 'UserMinus',
|
||||||
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
|
||||||
[NotificationTypes.REMINDER_24H]: 'Clock',
|
[NotificationTypes.REMINDER_24H]: 'Clock',
|
||||||
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
|
||||||
@@ -131,6 +133,7 @@ export const NotificationPriorities: Record<string, NotificationPriority> = {
|
|||||||
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
|
||||||
[NotificationTypes.COI_REASSIGNED]: 'high',
|
[NotificationTypes.COI_REASSIGNED]: 'high',
|
||||||
[NotificationTypes.MANUAL_REASSIGNED]: 'high',
|
[NotificationTypes.MANUAL_REASSIGNED]: 'high',
|
||||||
|
[NotificationTypes.DROPOUT_REASSIGNED]: 'high',
|
||||||
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
|
||||||
[NotificationTypes.DEADLINE_24H]: 'high',
|
[NotificationTypes.DEADLINE_24H]: 'high',
|
||||||
[NotificationTypes.REMINDER_24H]: 'high',
|
[NotificationTypes.REMINDER_24H]: 'high',
|
||||||
|
|||||||
Reference in New Issue
Block a user