Jury management: create, delete, add/remove members from round detail page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s
- 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:
@@ -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 "{juryGroup?.name}"
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{juryGroup?.name}" 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 — {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">
|
||||
|
||||
Reference in New Issue
Block a user