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,552 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
Plus,
Layers,
Calendar,
ChevronDown,
ChevronRight,
Settings,
Users,
FileBox,
Save,
Loader2,
} from 'lucide-react'
import { useEdition } from '@/contexts/edition-context'
const ROUND_TYPES = [
{ value: 'INTAKE', label: 'Intake' },
{ value: 'FILTERING', label: 'Filtering' },
{ value: 'EVALUATION', label: 'Evaluation' },
{ value: 'SUBMISSION', label: 'Submission' },
{ value: 'MENTORING', label: 'Mentoring' },
{ value: 'LIVE_FINAL', label: 'Live Final' },
{ value: 'DELIBERATION', label: 'Deliberation' },
] as const
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const statusConfig = {
DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700', dotClass: 'bg-gray-500' },
ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700', dotClass: 'bg-emerald-500' },
CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700', dotClass: 'bg-blue-500' },
ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground', dotClass: 'bg-muted-foreground' },
} as const
const roundStatusColors: Record<string, string> = {
ROUND_DRAFT: 'bg-gray-100 text-gray-600',
ROUND_ACTIVE: 'bg-emerald-100 text-emerald-700',
ROUND_CLOSED: 'bg-blue-100 text-blue-700',
ROUND_ARCHIVED: 'bg-muted text-muted-foreground',
}
export default function RoundsPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const utils = trpc.useUtils()
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' })
const [expandedCompetitions, setExpandedCompetitions] = useState<Set<string>>(new Set())
const [editingCompetition, setEditingCompetition] = useState<string | null>(null)
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
const [filterType, setFilterType] = useState<string>('all')
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
const createRoundMutation = trpc.round.create.useMutation({
onSuccess: () => {
utils.competition.list.invalidate()
toast.success('Round created')
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: '', competitionId: '' })
},
onError: (err) => toast.error(err.message),
})
const updateCompMutation = trpc.competition.update.useMutation({
onSuccess: () => {
utils.competition.list.invalidate()
toast.success('Competition settings saved')
setEditingCompetition(null)
setCompetitionEdits({})
},
onError: (err) => toast.error(err.message),
})
const toggleExpanded = (id: string) => {
setExpandedCompetitions((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !roundForm.competitionId) {
toast.error('All fields are required')
return
}
const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const comp = competitions?.find((c) => c.id === roundForm.competitionId)
const nextOrder = comp ? (comp as any).rounds?.length ?? comp._count.rounds : 0
createRoundMutation.mutate({
competitionId: roundForm.competitionId,
name: roundForm.name.trim(),
slug,
roundType: roundForm.roundType as any,
sortOrder: nextOrder,
})
}
const startEditCompetition = (comp: any) => {
setEditingCompetition(comp.id)
setCompetitionEdits({
name: comp.name,
categoryMode: comp.categoryMode,
startupFinalistCount: comp.startupFinalistCount,
conceptFinalistCount: comp.conceptFinalistCount,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach,
})
}
const saveCompetitionEdit = (id: string) => {
updateCompMutation.mutate({ id, ...competitionEdits } as any)
}
if (!programId) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Rounds</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar 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">Rounds</h1>
<p className="text-sm text-muted-foreground">
Manage all competition rounds for {currentEdition?.name}
</p>
</div>
<Button size="sm" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</div>
{/* Filter */}
<div className="flex items-center gap-3">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Loading */}
{isLoading && (
<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-12 w-full" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!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">
<Layers 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 rounds to define the evaluation flow.
</p>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Competition
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Competition Groups with Rounds */}
{competitions && competitions.length > 0 && (
<div className="space-y-4">
{competitions.map((comp) => {
const status = comp.status as keyof typeof statusConfig
const cfg = statusConfig[status] || statusConfig.DRAFT
const isExpanded = expandedCompetitions.has(comp.id) || competitions.length === 1
const isEditing = editingCompetition === comp.id
return (
<CompetitionGroup
key={comp.id}
competition={comp}
statusConfig={cfg}
isExpanded={isExpanded}
isEditing={isEditing}
competitionEdits={competitionEdits}
filterType={filterType}
updateCompMutation={updateCompMutation}
onToggle={() => toggleExpanded(comp.id)}
onStartEdit={() => startEditCompetition(comp)}
onCancelEdit={() => { setEditingCompetition(null); setCompetitionEdits({}) }}
onSaveEdit={() => saveCompetitionEdit(comp.id)}
onEditChange={setCompetitionEdits}
/>
)
})}
</div>
)}
{/* Add Round Dialog */}
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Round</DialogTitle>
<DialogDescription>
Create a new round in a competition.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Competition *</Label>
<Select
value={roundForm.competitionId}
onValueChange={(v) => setRoundForm({ ...roundForm, 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>Round Name *</Label>
<Input
placeholder="e.g. Initial Screening"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Round Type *</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Competition Group Component ─────────────────────────────────────────────
type CompetitionGroupProps = {
competition: any
statusConfig: { label: string; bgClass: string; dotClass: string }
isExpanded: boolean
isEditing: boolean
competitionEdits: Record<string, unknown>
filterType: string
updateCompMutation: any
onToggle: () => void
onStartEdit: () => void
onCancelEdit: () => void
onSaveEdit: () => void
onEditChange: (edits: Record<string, unknown>) => void
}
function CompetitionGroup({
competition: comp,
statusConfig: cfg,
isExpanded,
isEditing,
competitionEdits,
filterType,
updateCompMutation,
onToggle,
onStartEdit,
onCancelEdit,
onSaveEdit,
onEditChange,
}: CompetitionGroupProps) {
// We need to fetch rounds for this competition
const { data: compDetail } = trpc.competition.getById.useQuery(
{ id: comp.id },
{ enabled: isExpanded }
)
const rounds = compDetail?.rounds ?? []
const filteredRounds = filterType === 'all'
? rounds
: rounds.filter((r: any) => r.roundType === filterType)
return (
<Card>
{/* Competition Header */}
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<button
onClick={onToggle}
className="shrink-0 rounded p-1 hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base cursor-pointer" onClick={onToggle}>
{comp.name}
</CardTitle>
<Badge variant="secondary" className={cn('text-[10px]', cfg.bgClass)}>
{cfg.label}
</Badge>
<span className="text-xs text-muted-foreground">
{comp._count.rounds} rounds
</span>
<span className="text-xs text-muted-foreground">
{comp._count.juryGroups} juries
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={(e) => { e.stopPropagation(); onStartEdit() }}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{/* Inline Competition Settings Editor */}
{isEditing && (
<CardContent className="border-t bg-muted/30 pt-4">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Competition Name</Label>
<Input
value={(competitionEdits.name as string) ?? ''}
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Category Mode</Label>
<Input
value={(competitionEdits.categoryMode as string) ?? ''}
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Startup Finalists</Label>
<Input
type="number"
min={1}
className="w-24"
value={(competitionEdits.startupFinalistCount as number) ?? 10}
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Concept Finalists</Label>
<Input
type="number"
min={1}
className="w-24"
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
/>
<Label className="text-xs">Notify on Advance</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
/>
<Label className="text-xs">Deadline Reminders</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={onSaveEdit}
disabled={updateCompMutation.isPending}
>
{updateCompMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
Save
</Button>
<Button size="sm" variant="outline" onClick={onCancelEdit}>Cancel</Button>
</div>
</div>
</CardContent>
)}
{/* Rounds List */}
{isExpanded && (
<CardContent className={cn(isEditing ? '' : 'pt-0')}>
{!compDetail ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : filteredRounds.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
</p>
) : (
<div className="space-y-2">
{filteredRounds.map((round: any, index: number) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}` as Route}
>
<div className="flex items-center gap-3 rounded-lg border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{round.sortOrder + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{round.name}</p>
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
</div>
<Badge
variant="secondary"
className={cn('text-[10px] shrink-0', roundTypeColors[round.roundType])}
>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge
variant="outline"
className={cn('text-[10px] shrink-0 hidden sm:inline-flex', roundStatusColors[round.status])}
>
{round.status.replace('ROUND_', '')}
</Badge>
</div>
</Link>
))}
</div>
)}
</CardContent>
)}
</Card>
)
}