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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 12:46:01 +01:00
parent 86fa542371
commit 763b2ef0f5
4 changed files with 335 additions and 45 deletions

View File

@ -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() {
</TabsContent>
)}
{/* ═══════════ JURY TAB ═══════════ */}
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{juryGroups && juryGroups.length > 0 ? (
<div className="space-y-4">
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
{/* Delete button for currently selected jury group */}
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-1.5" />
Delete &quot;{juryGroup?.name}&quot;
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{juryGroup?.name}&quot; and remove all its members.
Rounds using this jury group will be unlinked. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJuryMutation.mutate({ id: round.juryGroupId! })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteJuryMutation.isPending}
>
{deleteJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Delete Jury
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-purple-50 p-4 mb-4">
<Users className="h-8 w-8 text-purple-400" />
</div>
<p className="text-sm font-medium">No Jury Groups</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Create a jury group to assign members who will evaluate projects in this round.
</p>
<Button size="sm" className="mt-4" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Create First Jury
</Button>
</div>
)}
</CardContent>
</Card>
{/* Members list (only if a jury group is assigned) */}
{juryGroupDetail && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
Members &mdash; {juryGroupDetail.name}
</CardTitle>
<CardDescription>
{juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{juryGroupDetail.members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<UserPlus className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Members Yet</p>
<p className="text-xs text-muted-foreground mt-1">
Add jury members to start assigning projects for evaluation.
</p>
<Button size="sm" variant="outline" className="mt-4" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add First Member
</Button>
</div>
) : (
<div className="space-y-1">
{juryGroupDetail.members.map((member: any, idx: number) => (
<div
key={member.id}
className={cn(
'flex items-center justify-between py-2.5 px-3 rounded-md transition-colors',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{member.user.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.user.name || member.user.email} from {juryGroupDetail.name}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeJuryMemberMutation.mutate({ id: member.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Create Jury Dialog */}
<Dialog open={createJuryOpen} onOpenChange={setCreateJuryOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. It will be automatically assigned to this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input
placeholder="e.g. Round 1 Jury, Expert Panel, Final Jury"
value={newJuryName}
onChange={(e) => 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, ''),
})
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateJuryOpen(false)}>Cancel</Button>
<Button
onClick={() => {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}}
disabled={createJuryMutation.isPending || !newJuryName.trim()}
>
{createJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Member Dialog */}
{juryGroupId && (
<AddMemberDialog
juryGroupId={juryGroupId}
open={addMemberOpen}
onOpenChange={(open) => {
setAddMemberOpen(open)
if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId })
}}
/>
)}
</TabsContent>
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">

View File

@ -36,14 +36,12 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
// Search existing user state
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('')
// Invite new user state
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('')
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
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="role">Group Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as typeof role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
@ -298,20 +280,6 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteGroupRole">Group Role</Label>
<Select value={inviteRole} onValueChange={(val) => setInviteRole(val as typeof inviteRole)}>
<SelectTrigger id="inviteGroupRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>

View File

@ -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
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
<TableHead>Actions</TableHead>
@ -90,7 +89,7 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
<TableCell colSpan={5} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
@ -103,11 +102,6 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
{member.role}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
</TableCell>

View File

@ -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
*/