From 6838b01724096920223aa8e33f1229735bd15343 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Feb 2026 18:23:54 +0100 Subject: [PATCH] Fix per-juror assignment caps: read correct field + inline edit UI - Fix bug: AI assignment router read non-existent `(m as any).maxAssignments` instead of the actual schema field `m.maxAssignmentsOverride` - Wire `jurorLimits` record into AI assignment constraints so per-juror caps are respected during both AI scoring and algorithmic assignment - Add inline editable cap in jury members table (click to edit, blur/enter to save, empty = no cap / use group default) - Add inline editable cap badges on round page member list so admins can set caps right from the assignment workflow Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 86 +++++++++++++++++++ .../admin/jury/jury-members-table.tsx | 84 +++++++++++++++++- src/server/routers/roundAssignment.ts | 11 ++- 3 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 577890c..c016819 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -84,6 +84,7 @@ import { MoreHorizontal, ShieldAlert, Eye, + Pencil, } from 'lucide-react' import { Command, @@ -370,6 +371,14 @@ export default function RoundDetailPage() { onError: (err) => toast.error(err.message), }) + const updateJuryMemberMutation = trpc.juryGroup.updateMember.useMutation({ + onSuccess: () => { + if (juryGroupId) utils.juryGroup.getById.invalidate({ id: juryGroupId }) + toast.success('Cap updated') + }, + onError: (err) => toast.error(err.message), + }) + const advanceMutation = trpc.round.advanceProjects.useMutation({ onSuccess: (data) => { utils.round.getById.invalidate({ id: roundId }) @@ -1533,6 +1542,15 @@ export default function RoundDetailPage() {

{member.user.email}

+
+ updateJuryMemberMutation.mutate({ + id: member.id, + maxAssignmentsOverride: val, + })} + />
))} @@ -2057,6 +2076,73 @@ export default function RoundDetailPage() { // Sub-components // ═══════════════════════════════════════════════════════════════════════════ +// ── Inline cap editor for jury members on round page ───────────────────── + +function InlineMemberCap({ + memberId, + currentValue, + onSave, +}: { + memberId: string + currentValue: number | null + onSave: (val: number | null) => void +}) { + const [editing, setEditing] = useState(false) + const [value, setValue] = useState(currentValue?.toString() ?? '') + const inputRef = useRef(null) + + useEffect(() => { + if (editing) inputRef.current?.focus() + }, [editing]) + + const save = () => { + const trimmed = value.trim() + const newVal = trimmed === '' ? null : parseInt(trimmed, 10) + if (newVal !== null && (isNaN(newVal) || newVal < 1)) { + toast.error('Enter a positive number or leave empty for no cap') + return + } + if (newVal === currentValue) { + setEditing(false) + return + } + onSave(newVal) + setEditing(false) + } + + if (editing) { + return ( + setValue(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) } + }} + /> + ) + } + + return ( + + ) +} + // ── Unassigned projects queue ──────────────────────────────────────────── function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) { diff --git a/src/components/admin/jury/jury-members-table.tsx b/src/components/admin/jury/jury-members-table.tsx index 7c7a426..f2725e3 100644 --- a/src/components/admin/jury/jury-members-table.tsx +++ b/src/components/admin/jury/jury-members-table.tsx @@ -1,10 +1,11 @@ 'use client' -import { useState } from 'react' -import { Trash2, UserPlus } from 'lucide-react' +import { useState, useRef, useEffect } from 'react' +import { Trash2, UserPlus, Pencil } from 'lucide-react' import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Table, TableBody, @@ -45,6 +46,79 @@ interface JuryMembersTableProps { members: JuryMember[] } +function InlineCapEdit({ + memberId, + currentValue, + juryGroupId, +}: { + memberId: string + currentValue: number | null | undefined + juryGroupId: string +}) { + const [editing, setEditing] = useState(false) + const [value, setValue] = useState(currentValue?.toString() ?? '') + const inputRef = useRef(null) + const utils = trpc.useUtils() + + const { mutate: updateMember, isPending } = trpc.juryGroup.updateMember.useMutation({ + onSuccess: () => { + utils.juryGroup.getById.invalidate({ id: juryGroupId }) + toast.success('Cap updated') + setEditing(false) + }, + onError: (err) => toast.error(err.message), + }) + + useEffect(() => { + if (editing) inputRef.current?.focus() + }, [editing]) + + const save = () => { + const trimmed = value.trim() + const newVal = trimmed === '' ? null : parseInt(trimmed, 10) + if (newVal !== null && (isNaN(newVal) || newVal < 1)) { + toast.error('Enter a positive number or leave empty for no cap') + return + } + if (newVal === (currentValue ?? null)) { + setEditing(false) + return + } + updateMember({ id: memberId, maxAssignmentsOverride: newVal }) + } + + if (editing) { + return ( + setValue(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) } + }} + /> + ) + } + + return ( + + ) +} + export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) { const [addDialogOpen, setAddDialogOpen] = useState(false) const [removingMemberId, setRemovingMemberId] = useState(null) @@ -103,7 +177,11 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps {member.user.email} - {member.maxAssignmentsOverride ?? '—'} + {member.capModeOverride ? ( diff --git a/src/server/routers/roundAssignment.ts b/src/server/routers/roundAssignment.ts index e66c009..5b2ef47 100644 --- a/src/server/routers/roundAssignment.ts +++ b/src/server/routers/roundAssignment.ts @@ -101,7 +101,7 @@ export const roundAssignmentRouter = router({ expertiseTags: (m.user.expertiseTags as string[]) ?? [], bio: m.user.bio as string | null, country: m.user.country as string | null, - maxAssignments: (m as any).maxAssignments as number | null ?? null, + maxAssignments: m.maxAssignmentsOverride ?? null, _count: { assignments: existingAssignments.filter((a) => a.userId === m.user.id).length, }, @@ -142,9 +142,18 @@ export const roundAssignmentRouter = router({ const calculatedMax = Math.ceil(totalNeeded / jurorCount) + 2 const maxPerJuror = configuredMax ?? calculatedMax + // Build per-juror cap overrides + const jurorLimits: Record = {} + for (const m of round.juryGroup.members) { + if (m.maxAssignmentsOverride != null) { + jurorLimits[m.user.id] = m.maxAssignmentsOverride + } + } + const constraints = { requiredReviewsPerProject: input.requiredReviews, maxAssignmentsPerJuror: maxPerJuror, + jurorLimits, existingAssignments: existingAssignments.map((a) => ({ jurorId: a.userId, projectId: a.projectId,