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:
@@ -11,6 +11,8 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -35,6 +37,9 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
Archive,
|
||||
Calendar,
|
||||
FileBox,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
@@ -65,6 +70,8 @@ export default function RoundDetailPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<string | null>(null)
|
||||
const [windowOpenAt, setWindowOpenAt] = useState<string>('')
|
||||
const [windowCloseAt, setWindowCloseAt] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -94,6 +101,11 @@ export default function RoundDetailPage() {
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
setConfig(roundConfig)
|
||||
}
|
||||
// Sync date fields
|
||||
const openStr = round.windowOpenAt ? new Date(round.windowOpenAt).toISOString().slice(0, 16) : ''
|
||||
const closeStr = round.windowCloseAt ? new Date(round.windowCloseAt).toISOString().slice(0, 16) : ''
|
||||
if (openStr !== windowOpenAt) setWindowOpenAt(openStr)
|
||||
if (closeStr !== windowCloseAt) setWindowCloseAt(closeStr)
|
||||
}
|
||||
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
@@ -139,7 +151,12 @@ export default function RoundDetailPage() {
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({ id: roundId, configJson: config })
|
||||
updateMutation.mutate({
|
||||
id: roundId,
|
||||
configJson: config,
|
||||
windowOpenAt: windowOpenAt ? new Date(windowOpenAt) : null,
|
||||
windowCloseAt: windowCloseAt ? new Date(windowCloseAt) : null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleLifecycleAction = () => {
|
||||
@@ -192,17 +209,23 @@ export default function RoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={"/admin/rounds" as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={"/admin/rounds" as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">Back to Rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', roundTypeColors[round.roundType])}>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-[#053d57]">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-sm font-medium px-3 py-1', roundTypeColors[round.roundType])}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
|
||||
@@ -210,11 +233,11 @@ export default function RoundDetailPage() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors hover:opacity-80',
|
||||
'inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1 rounded-full transition-colors hover:opacity-80',
|
||||
statusCfg.bgClass,
|
||||
)}>
|
||||
{statusCfg.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
@@ -247,38 +270,158 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#053d57]/10 to-[#557f8c]/10">
|
||||
<FileBox className="h-5 w-5 text-[#053d57]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-[#053d57]">{round._count?.projectRoundStates ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#557f8c]/10 to-[#053d57]/10">
|
||||
<Users className="h-5 w-5 text-[#557f8c]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-[#053d57]">{round.juryGroup?.members?.length ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Members</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#de0f1e]/10 to-[#de0f1e]/5">
|
||||
<Users className="h-5 w-5 text-[#de0f1e]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{round.juryGroup ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold text-[#053d57] truncate">{round.juryGroup.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Group</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-muted-foreground">No jury assigned</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Group</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-amber-500/10 to-amber-500/5">
|
||||
<Calendar className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{round.windowOpenAt || round.windowCloseAt ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-[#053d57] truncate">
|
||||
{round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
{round.windowOpenAt && round.windowCloseAt && ' - '}
|
||||
{round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Schedule</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-medium text-muted-foreground">Not scheduled</p>
|
||||
<p className="text-xs text-muted-foreground">Schedule</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Schedule Editor */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="h-4 w-4 text-[#053d57]" />
|
||||
<h3 className="text-sm font-semibold text-[#053d57]">Round Schedule</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Opens At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={windowOpenAt}
|
||||
onChange={(e) => { setWindowOpenAt(e.target.value); setHasChanges(true) }}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Closes At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={windowCloseAt}
|
||||
onChange={(e) => { setWindowCloseAt(e.target.value); setHasChanges(true) }}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="config" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="awards">
|
||||
<Tabs defaultValue="config" className="space-y-6">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto bg-muted/50 p-1 h-auto">
|
||||
<TabsTrigger value="config" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Configuration
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="projects" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Projects
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="windows" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Submission Windows
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="documents" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Awards
|
||||
{roundAwards.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
<Badge variant="secondary" className="ml-2 bg-[#de0f1e] text-white">
|
||||
{roundAwards.length}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user