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 + + + + setCreateJuryOpen(true)}> + + New Jury + + + + + + {juryGroups && juryGroups.length > 0 ? ( + + { + assignJuryMutation.mutate({ + id: roundId, + juryGroupId: value === '__none__' ? null : value, + }) + }} + disabled={assignJuryMutation.isPending} + > + + + + + No jury assigned + {juryGroups.map((jg: any) => ( + + {jg.name} ({jg._count?.members ?? 0} members) + + ))} + + + + {/* Delete button for currently selected jury group */} + {round.juryGroupId && ( + + + + + Delete "{juryGroup?.name}" + + + + + 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. + + setCreateJuryOpen(true)}> + + Create First Jury + + + )} + + + + {/* Members list (only if a jury group is assigned) */} + {juryGroupDetail && ( + + + + + + Members — {juryGroupDetail.name} + + + {juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''} + + + setAddMemberOpen(true)}> + + Add Member + + + + + {juryGroupDetail.members.length === 0 ? ( + + + + + No Members Yet + + Add jury members to start assigning projects for evaluation. + + setAddMemberOpen(true)}> + + Add First Member + + + ) : ( + + {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. + + + + + Name + 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, ''), + }) + } + }} + /> + + + + setCreateJuryOpen(false)}>Cancel + { + createJuryMutation.mutate({ + competitionId, + name: newJuryName.trim(), + slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), + }) + }} + disabled={createJuryMutation.isPending || !newJuryName.trim()} + > + {createJuryMutation.isPending && } + Create + + + + + + {/* 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 - - Group Role - setRole(val as typeof role)}> - - - - - Member - Chair - Observer - - - - Cap Mode @@ -298,20 +280,6 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi - - Group Role - setInviteRole(val as typeof inviteRole)}> - - - - - Member - Chair - Observer - - - - Cap Mode diff --git a/src/components/admin/jury/jury-members-table.tsx b/src/components/admin/jury/jury-members-table.tsx index 7f1e057..7c7a426 100644 --- a/src/components/admin/jury/jury-members-table.tsx +++ b/src/components/admin/jury/jury-members-table.tsx @@ -29,15 +29,15 @@ import { AddMemberDialog } from './add-member-dialog' interface JuryMember { id: string userId: string - role: string + role?: string user: { id: string name: string | null email: string } - maxAssignmentsOverride: number | null - capModeOverride: string | null - preferredStartupRatio: number | null + maxAssignmentsOverride?: number | null + capModeOverride?: string | null + preferredStartupRatio?: number | null } interface JuryMembersTableProps { @@ -81,7 +81,6 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps Name Email - Role Max Assignments Cap Mode Actions @@ -90,7 +89,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps {members.length === 0 ? ( - + No members yet. Add members to get started. @@ -103,11 +102,6 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps {member.user.email} - - - {member.role} - - {member.maxAssignmentsOverride ?? '—'} diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts index a27dd7f..4c5a16f 100644 --- a/src/server/routers/juryGroup.ts +++ b/src/server/routers/juryGroup.ts @@ -249,6 +249,50 @@ export const juryGroupRouter = router({ return existing }), + /** + * Delete a jury group entirely + */ + delete: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ + where: { id: input.id }, + include: { + _count: { select: { assignments: true, rounds: true } }, + }, + }) + + // Unlink any rounds that reference this jury group + await ctx.prisma.round.updateMany({ + where: { juryGroupId: input.id }, + data: { juryGroupId: null }, + }) + + // Delete all members first (cascade should handle this, but be explicit) + await ctx.prisma.juryGroupMember.deleteMany({ + where: { juryGroupId: input.id }, + }) + + await ctx.prisma.juryGroup.delete({ where: { id: input.id } }) + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'DELETE', + entityType: 'JuryGroup', + entityId: input.id, + detailsJson: { + name: group.name, + competitionId: group.competitionId, + memberCount: group._count.assignments, + }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + + return { success: true, name: group.name } + }), + /** * Update a jury group member's role/overrides */
No Jury Groups
+ Create a jury group to assign members who will evaluate projects in this round. +
No Members Yet
+ Add jury members to start assigning projects for evaluation. +
+ {member.user.name || 'Unnamed User'} +
{member.user.email}