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>
553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
'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>
|
|
)
|
|
}
|