Fix per-juror assignment caps: read correct field + inline edit UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Eye,
|
Eye,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -370,6 +371,14 @@ export default function RoundDetailPage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
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({
|
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
@@ -1533,6 +1542,15 @@ export default function RoundDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
|
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<InlineMemberCap
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride as number | null}
|
||||||
|
onSave={(val) => updateJuryMemberMutation.mutate({
|
||||||
|
id: member.id,
|
||||||
|
maxAssignmentsOverride: val,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -1561,6 +1579,7 @@ export default function RoundDetailPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2057,6 +2076,73 @@ export default function RoundDetailPage() {
|
|||||||
// Sub-components
|
// 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<HTMLInputElement>(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 (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-6 w-16 text-xs"
|
||||||
|
value={value}
|
||||||
|
placeholder="\u221E"
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') save()
|
||||||
|
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-muted transition-colors group"
|
||||||
|
title="Click to set max assignment cap"
|
||||||
|
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">max:</span>
|
||||||
|
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
||||||
|
<Pencil className="h-2.5 w-2.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Unassigned projects queue ────────────────────────────────────────────
|
// ── Unassigned projects queue ────────────────────────────────────────────
|
||||||
|
|
||||||
function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) {
|
function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Trash2, UserPlus } from 'lucide-react'
|
import { Trash2, UserPlus, Pencil } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -45,6 +46,79 @@ interface JuryMembersTableProps {
|
|||||||
members: JuryMember[]
|
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<HTMLInputElement>(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 (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-7 w-20 text-xs"
|
||||||
|
value={value}
|
||||||
|
placeholder="∞"
|
||||||
|
disabled={isPending}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') save()
|
||||||
|
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-sm hover:bg-muted transition-colors group"
|
||||||
|
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||||||
|
>
|
||||||
|
<span>{currentValue ?? '∞'}</span>
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
|
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
@@ -103,7 +177,11 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
|
|||||||
{member.user.email}
|
{member.user.email}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
{member.maxAssignmentsOverride ?? '—'}
|
<InlineCapEdit
|
||||||
|
memberId={member.id}
|
||||||
|
currentValue={member.maxAssignmentsOverride}
|
||||||
|
juryGroupId={juryGroupId}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
{member.capModeOverride ? (
|
{member.capModeOverride ? (
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const roundAssignmentRouter = router({
|
|||||||
expertiseTags: (m.user.expertiseTags as string[]) ?? [],
|
expertiseTags: (m.user.expertiseTags as string[]) ?? [],
|
||||||
bio: m.user.bio as string | null,
|
bio: m.user.bio as string | null,
|
||||||
country: m.user.country as string | null,
|
country: m.user.country as string | null,
|
||||||
maxAssignments: (m as any).maxAssignments as number | null ?? null,
|
maxAssignments: m.maxAssignmentsOverride ?? null,
|
||||||
_count: {
|
_count: {
|
||||||
assignments: existingAssignments.filter((a) => a.userId === m.user.id).length,
|
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 calculatedMax = Math.ceil(totalNeeded / jurorCount) + 2
|
||||||
const maxPerJuror = configuredMax ?? calculatedMax
|
const maxPerJuror = configuredMax ?? calculatedMax
|
||||||
|
|
||||||
|
// Build per-juror cap overrides
|
||||||
|
const jurorLimits: Record<string, number> = {}
|
||||||
|
for (const m of round.juryGroup.members) {
|
||||||
|
if (m.maxAssignmentsOverride != null) {
|
||||||
|
jurorLimits[m.user.id] = m.maxAssignmentsOverride
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const constraints = {
|
const constraints = {
|
||||||
requiredReviewsPerProject: input.requiredReviews,
|
requiredReviewsPerProject: input.requiredReviews,
|
||||||
maxAssignmentsPerJuror: maxPerJuror,
|
maxAssignmentsPerJuror: maxPerJuror,
|
||||||
|
jurorLimits,
|
||||||
existingAssignments: existingAssignments.map((a) => ({
|
existingAssignments: existingAssignments.map((a) => ({
|
||||||
jurorId: a.userId,
|
jurorId: a.userId,
|
||||||
projectId: a.projectId,
|
projectId: a.projectId,
|
||||||
|
|||||||
Reference in New Issue
Block a user