Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper - Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router - Add per-round analytics (_count, juryGroup) to competition.getById - Remove redundant acceptedCategories from intake config - Rewrite submission window manager with full CRUD, all fields, date pickers - Add round scheduling card (open/close dates) to round detail page - Add project count, assignment count, jury group to round list cards - Visual redesign: pipeline view, brand colors, progress bars, enhanced cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,19 @@ const roundStatusColors: Record<string, string> = {
|
||||
ROUND_ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
type RoundWithStats = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
status: string
|
||||
sortOrder: number
|
||||
windowOpenAt: string | null
|
||||
windowCloseAt: string | null
|
||||
juryGroup: { id: string; name: string } | null
|
||||
_count: { projectRoundStates: number; assignments: number }
|
||||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
@@ -183,33 +196,36 @@ export default function RoundsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start 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}
|
||||
<h1 className="text-3xl font-bold tracking-tight text-[#053d57]">Competition Rounds</h1>
|
||||
<p className="text-base text-muted-foreground mt-1.5">
|
||||
Configure and manage evaluation rounds for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddRoundOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Button size="default" onClick={() => setAddRoundOpen(true)} className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
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>
|
||||
<Card className="p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm font-medium shrink-0">Filter:</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-48">
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
@@ -230,17 +246,17 @@ export default function RoundsPage() {
|
||||
|
||||
{/* 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" />
|
||||
<Card className="border-2 border-dashed shadow-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="rounded-full bg-gradient-to-br from-[#de0f1e]/10 to-[#053d57]/10 p-6 mb-6">
|
||||
<Layers className="h-12 w-12 text-[#de0f1e]" />
|
||||
</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">
|
||||
<h3 className="text-xl font-semibold mb-2 text-[#053d57]">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6 leading-relaxed">
|
||||
Create a competition first, then add rounds to define the evaluation flow.
|
||||
</p>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Competition
|
||||
</Button>
|
||||
@@ -379,48 +395,53 @@ function CompetitionGroup({
|
||||
{ enabled: isExpanded }
|
||||
)
|
||||
|
||||
const rounds = compDetail?.rounds ?? []
|
||||
const rounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||
const filteredRounds = filterType === 'all'
|
||||
? rounds
|
||||
: rounds.filter((r: any) => r.roundType === filterType)
|
||||
: rounds.filter((r: RoundWithStats) => r.roundType === filterType)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="shadow-sm border-l-4 transition-shadow hover:shadow-md" style={{ borderLeftColor: cfg.dotClass.includes('emerald') ? '#10b981' : cfg.dotClass.includes('blue') ? '#3b82f6' : '#9ca3af' }}>
|
||||
{/* Competition Header */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="shrink-0 rounded p-1 hover:bg-muted transition-colors"
|
||||
className="shrink-0 rounded-lg p-2 hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronRight className="h-5 w-5 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}>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<CardTitle className="text-lg font-semibold cursor-pointer text-[#053d57]" onClick={onToggle}>
|
||||
{comp.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', cfg.bgClass)}>
|
||||
<Badge variant="secondary" className={cn('text-xs font-medium px-2.5 py-0.5', 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 className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{comp._count.rounds} rounds</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>{comp._count.juryGroups} juries</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onStartEdit() }}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
@@ -430,72 +451,78 @@ function CompetitionGroup({
|
||||
|
||||
{/* 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>
|
||||
<CardContent className="border-t bg-gradient-to-br from-muted/30 to-muted/10 pt-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#053d57] mb-4">Competition Settings</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Competition Name</Label>
|
||||
<Input
|
||||
value={(competitionEdits.name as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Category Mode</Label>
|
||||
<Input
|
||||
value={(competitionEdits.categoryMode as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Startup Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9"
|
||||
value={(competitionEdits.startupFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Concept Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9"
|
||||
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
||||
/>
|
||||
<Label className="text-xs font-medium">Notify on Advance</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
|
||||
/>
|
||||
<Label className="text-xs font-medium">Deadline Reminders</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
onClick={onSaveEdit}
|
||||
disabled={updateCompMutation.isPending}
|
||||
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
||||
>
|
||||
{updateCompMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelEdit}>Cancel</Button>
|
||||
<Button size="default" variant="outline" onClick={onCancelEdit}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -503,46 +530,113 @@ function CompetitionGroup({
|
||||
|
||||
{/* Rounds List */}
|
||||
{isExpanded && (
|
||||
<CardContent className={cn(isEditing ? '' : 'pt-0')}>
|
||||
<CardContent className={cn(isEditing ? 'pt-6' : 'pt-2')}>
|
||||
{!compDetail ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 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="py-12 text-center">
|
||||
<FileBox className="h-10 w-10 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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 className="relative space-y-3">
|
||||
{filteredRounds.map((round: RoundWithStats, index: number) => {
|
||||
const isLast = index === filteredRounds.length - 1
|
||||
const projectCount = round._count.projectRoundStates
|
||||
const progressPercent = projectCount > 0 ? Math.min((round._count.assignments / (projectCount * 3)) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div key={round.id} className="relative">
|
||||
<Link href={`/admin/rounds/${round.id}` as Route}>
|
||||
<div className="flex items-start gap-4 rounded-xl border-2 border-border bg-card p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg hover:border-[#de0f1e]/30 cursor-pointer group">
|
||||
{/* Round Number Circle */}
|
||||
<div className="flex flex-col items-center gap-2 shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-[#053d57] to-[#557f8c] text-sm font-bold text-white shadow-md">
|
||||
{round.sortOrder + 1}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="h-8 w-0.5 bg-gradient-to-b from-[#053d57]/30 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Round Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-base font-semibold truncate text-[#053d57] group-hover:text-[#de0f1e] transition-colors">
|
||||
{round.name}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground mt-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileBox className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{projectCount}</span>
|
||||
<span>projects</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{round._count.assignments}</span>
|
||||
<span>assignments</span>
|
||||
</div>
|
||||
{round.juryGroup && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="truncate">Jury: {round.juryGroup.name}</span>
|
||||
</>
|
||||
)}
|
||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span className="truncate">
|
||||
{round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
{round.windowOpenAt && round.windowCloseAt && ' - '}
|
||||
{round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-xs font-medium px-2.5 py-1', roundTypeColors[round.roundType])}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs font-medium px-2.5 py-1 hidden sm:inline-flex', roundStatusColors[round.status])}
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{projectCount > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#557f8c] to-[#053d57] transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user