Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s

- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:16:55 +01:00
parent fbb194067d
commit 4c0efb232c
23 changed files with 5745 additions and 891 deletions

View File

@@ -0,0 +1,307 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
const capModeLabels = {
HARD: 'Hard Cap',
SOFT: 'Soft Cap',
NONE: 'No Cap',
}
const capModeColors = {
HARD: 'bg-red-100 text-red-700',
SOFT: 'bg-amber-100 text-amber-700',
NONE: 'bg-gray-100 text-gray-600',
}
export default function JuriesPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const utils = trpc.useUtils()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [formData, setFormData] = useState({
competitionId: '',
name: '',
description: '',
})
const { data: competitions, isLoading: loadingCompetitions } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const createMutation = trpc.juryGroup.create.useMutation({
onSuccess: () => {
utils.juryGroup.list.invalidate()
toast.success('Jury group created')
setCreateDialogOpen(false)
setFormData({ competitionId: '', name: '', description: '' })
},
onError: (err) => toast.error(err.message),
})
const handleCreate = () => {
if (!formData.competitionId || !formData.name.trim()) {
toast.error('Competition and name are required')
return
}
const slug = formData.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
createMutation.mutate({
competitionId: formData.competitionId,
name: formData.name.trim(),
slug,
description: formData.description || undefined,
})
}
if (!programId) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Juries</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Scale className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">Select an edition from the sidebar</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold">Juries</h1>
<p className="text-sm text-muted-foreground">
Manage jury groups for {currentEdition?.name}
</p>
</div>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Create Jury Group
</Button>
</div>
{/* Loading */}
{loadingCompetitions && (
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!loadingCompetitions && (!competitions || competitions.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Scale className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Create a competition first, then add jury groups.
</p>
</CardContent>
</Card>
)}
{/* Competition Groups */}
{competitions && competitions.length > 0 && (
<div className="space-y-6">
{competitions.map((comp) => (
<CompetitionJuriesSection key={comp.id} competition={comp} />
))}
</div>
)}
{/* Create Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury panel for a competition.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Competition *</Label>
<Select
value={formData.competitionId}
onValueChange={(v) => setFormData({ ...formData, competitionId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Select competition" />
</SelectTrigger>
<SelectContent>
{competitions?.filter((c) => c.status !== 'ARCHIVED').map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Name *</Label>
<Input
placeholder="e.g. Technical Panel A"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Optional description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Creating...
</>
) : (
'Create'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Competition Section ─────────────────────────────────────────────────────
type CompetitionJuriesSectionProps = {
competition: any
}
function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps) {
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({
competitionId: competition.id,
})
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{competition.name}</CardTitle>
<CardDescription>
{juryGroups?.length || 0} jury group{juryGroups?.length === 1 ? '' : 's'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : !juryGroups || juryGroups.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No jury groups configured for this competition.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{juryGroups.map((group) => (
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
<Badge
variant="secondary"
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="h-3 w-3" />
<span>{group._count.members} members</span>
</div>
<div>
{group._count.assignments} assignments
</div>
</div>
<div className="text-xs text-muted-foreground">
Default max: {group.defaultMaxAssignments}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}