All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import type { Route } from 'next'
|
|
import { ArrowLeft, Plus, Users } from 'lucide-react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { toast } from 'sonner'
|
|
|
|
export default function JuriesListPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const competitionId = params.competitionId as string
|
|
const utils = trpc.useUtils()
|
|
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
description: '',
|
|
})
|
|
|
|
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
|
|
|
|
const createMutation = trpc.juryGroup.create.useMutation({
|
|
onSuccess: () => {
|
|
utils.juryGroup.list.invalidate({ competitionId })
|
|
toast.success('Jury group created')
|
|
setCreateOpen(false)
|
|
setFormData({ name: '', description: '' })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const handleCreate = () => {
|
|
if (!formData.name.trim()) {
|
|
toast.error('Name is required')
|
|
return
|
|
}
|
|
const slug = formData.name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
createMutation.mutate({
|
|
competitionId,
|
|
name: formData.name.trim(),
|
|
slug,
|
|
description: formData.description.trim() || undefined,
|
|
})
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
<Skeleton className="h-10 w-full" />
|
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-40" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto space-y-6 p-4 sm:p-6">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => router.back()}
|
|
className="mb-4"
|
|
aria-label="Back to competition details"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Jury Groups</h1>
|
|
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
|
|
</div>
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Jury Group
|
|
</Button>
|
|
</div>
|
|
|
|
{juryGroups && juryGroups.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
|
{juryGroups?.map((group) => (
|
|
<Link
|
|
key={group.id}
|
|
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
|
|
>
|
|
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
{group.name}
|
|
<Badge variant="secondary">{group.defaultCapMode}</Badge>
|
|
</CardTitle>
|
|
<CardDescription>{group.slug}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Members</span>
|
|
<span className="font-medium">{group._count.members}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Assignments</span>
|
|
<span className="font-medium">{group._count.assignments || 0}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Default Max</span>
|
|
<span className="font-medium">{group.defaultMaxAssignments}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Jury Group Dialog */}
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Jury Group</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new jury group for this competition. You can add members after creation.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jury-name">Name *</Label>
|
|
<Input
|
|
id="jury-name"
|
|
placeholder="e.g. Main Jury Panel"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jury-description">Description</Label>
|
|
<Textarea
|
|
id="jury-description"
|
|
placeholder="Optional description of this jury group's role"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'Creating...' : 'Create'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|