From 763b2ef0f56fb94f38429a2f9a7563c9045532f4 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 12:46:01 +0100 Subject: [PATCH] Jury management: create, delete, add/remove members from round detail page - Added Jury tab to round detail page with full jury management inline - Create new jury groups (auto-assigns to current round) - Delete jury groups with confirmation dialog - Add/remove members with inline member list - Assign/switch jury groups via dropdown selector - Added delete endpoint to juryGroup router (unlinks rounds, deletes members) - Removed CHAIR/OBSERVER role selectors from add-member dialog (all members) - Removed role column from jury-members-table Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 284 ++++++++++++++++++ .../admin/jury/add-member-dialog.tsx | 36 +-- .../admin/jury/jury-members-table.tsx | 16 +- src/server/routers/juryGroup.ts | 44 +++ 4 files changed, 335 insertions(+), 45 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 81b1557..90da292 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -88,6 +88,7 @@ import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { AnimatedCard } from '@/components/shared/animated-container' +import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog' import { motion } from 'motion/react' // ── Status & type config maps ────────────────────────────────────────────── @@ -156,6 +157,9 @@ export default function RoundDetailPage() { BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }> } | null>(null) const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false) + const [createJuryOpen, setCreateJuryOpen] = useState(false) + const [newJuryName, setNewJuryName] = useState('') + const [addMemberOpen, setAddMemberOpen] = useState(false) const utils = trpc.useUtils() @@ -249,11 +253,48 @@ export default function RoundDetailPage() { const assignJuryMutation = trpc.round.update.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) + utils.juryGroup.list.invalidate({ competitionId }) toast.success('Jury group updated') }, onError: (err) => toast.error(err.message), }) + // Jury group detail query (for the assigned group) + const juryGroupId = round?.juryGroupId ?? '' + const { data: juryGroupDetail } = trpc.juryGroup.getById.useQuery( + { id: juryGroupId }, + { enabled: !!juryGroupId, refetchInterval: 10_000 }, + ) + + const createJuryMutation = trpc.juryGroup.create.useMutation({ + onSuccess: (newGroup) => { + utils.juryGroup.list.invalidate({ competitionId }) + // Auto-assign the new jury group to this round + assignJuryMutation.mutate({ id: roundId, juryGroupId: newGroup.id }) + toast.success(`Jury "${newGroup.name}" created and assigned`) + setCreateJuryOpen(false) + setNewJuryName('') + }, + onError: (err) => toast.error(err.message), + }) + + const deleteJuryMutation = trpc.juryGroup.delete.useMutation({ + onSuccess: (result) => { + utils.juryGroup.list.invalidate({ competitionId }) + utils.round.getById.invalidate({ id: roundId }) + toast.success(`Jury "${result.name}" deleted`) + }, + onError: (err) => toast.error(err.message), + }) + + const removeJuryMemberMutation = trpc.juryGroup.removeMember.useMutation({ + onSuccess: () => { + if (juryGroupId) utils.juryGroup.getById.invalidate({ id: juryGroupId }) + toast.success('Member removed') + }, + onError: (err) => toast.error(err.message), + }) + const advanceMutation = trpc.round.advanceProjects.useMutation({ onSuccess: (data) => { utils.round.getById.invalidate({ id: roundId }) @@ -653,6 +694,7 @@ export default function RoundDetailPage() { { value: 'projects', label: 'Projects', icon: Layers }, ...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []), + { value: 'jury', label: 'Jury', icon: Users }, { value: 'config', label: 'Config', icon: Settings }, { value: 'windows', label: 'Submissions', icon: Clock }, { value: 'awards', label: 'Awards', icon: Trophy }, @@ -1128,6 +1170,248 @@ export default function RoundDetailPage() { )} + {/* ═══════════ JURY TAB ═══════════ */} + + {/* Jury Group Selector + Create */} + + +
+
+ Jury Group + + Select or create a jury group for this round + +
+
+ +
+
+
+ + {juryGroups && juryGroups.length > 0 ? ( +
+ + + {/* Delete button for currently selected jury group */} + {round.juryGroupId && ( + + + + + + + Delete jury group? + + This will permanently delete "{juryGroup?.name}" and remove all its members. + Rounds using this jury group will be unlinked. This action cannot be undone. + + + + Cancel + deleteJuryMutation.mutate({ id: round.juryGroupId! })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={deleteJuryMutation.isPending} + > + {deleteJuryMutation.isPending && } + Delete Jury + + + + + )} +
+ ) : ( +
+
+ +
+

No Jury Groups

+

+ Create a jury group to assign members who will evaluate projects in this round. +

+ +
+ )} +
+
+ + {/* Members list (only if a jury group is assigned) */} + {juryGroupDetail && ( + + +
+
+ + Members — {juryGroupDetail.name} + + + {juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''} + +
+ +
+
+ + {juryGroupDetail.members.length === 0 ? ( +
+
+ +
+

No Members Yet

+

+ Add jury members to start assigning projects for evaluation. +

+ +
+ ) : ( +
+ {juryGroupDetail.members.map((member: any, idx: number) => ( +
+
+

+ {member.user.name || 'Unnamed User'} +

+

{member.user.email}

+
+ + + + + + + Remove member? + + Remove {member.user.name || member.user.email} from {juryGroupDetail.name}? + + + + Cancel + removeJuryMemberMutation.mutate({ id: member.id })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + +
+ ))} +
+ )} +
+
+ )} + + {/* Create Jury Dialog */} + + + + Create Jury Group + + Create a new jury group for this competition. It will be automatically assigned to this round. + + +
+
+ + setNewJuryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newJuryName.trim()) { + createJuryMutation.mutate({ + competitionId, + name: newJuryName.trim(), + slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), + }) + } + }} + /> +
+
+ + + + +
+
+ + {/* Add Member Dialog */} + {juryGroupId && ( + { + setAddMemberOpen(open) + if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId }) + }} + /> + )} +
+ {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */} {isEvaluation && ( diff --git a/src/components/admin/jury/add-member-dialog.tsx b/src/components/admin/jury/add-member-dialog.tsx index 67d6bb3..53a54b6 100644 --- a/src/components/admin/jury/add-member-dialog.tsx +++ b/src/components/admin/jury/add-member-dialog.tsx @@ -36,14 +36,12 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi // Search existing user state const [searchQuery, setSearchQuery] = useState('') const [selectedUserId, setSelectedUserId] = useState('') - const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') const [maxAssignments, setMaxAssignments] = useState('') const [capMode, setCapMode] = useState('') // Invite new user state const [inviteName, setInviteName] = useState('') const [inviteEmail, setInviteEmail] = useState('') - const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') const [inviteMaxAssignments, setInviteMaxAssignments] = useState('') const [inviteCapMode, setInviteCapMode] = useState('') const [inviteExpertise, setInviteExpertise] = useState('') @@ -75,7 +73,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi addMember({ juryGroupId, userId: newUser.id, - role: inviteRole, + role: 'MEMBER', maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null, capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null, }) @@ -100,12 +98,10 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi const resetForm = () => { setSearchQuery('') setSelectedUserId('') - setRole('MEMBER') setMaxAssignments('') setCapMode('') setInviteName('') setInviteEmail('') - setInviteRole('MEMBER') setInviteMaxAssignments('') setInviteCapMode('') setInviteExpertise('') @@ -122,7 +118,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi addMember({ juryGroupId, userId: selectedUserId, - role, + role: 'MEMBER', maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null, capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null, }) @@ -215,20 +211,6 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
-
- - -
-
setInviteRole(val as typeof inviteRole)}> - - - - - Member - Chair - Observer - - -
-