Fix round reopen bug + redesign round detail page UI
Build and Push Docker Image / build (push) Failing after 18s Details

Round engine: moved logAudit() calls outside $transaction blocks to prevent
FK violations from poisoning PostgreSQL transactions and rolling back status changes.

Round detail page: redesigned with Editorial Command Center aesthetic -
dark blue gradient header, colored accent stat cards, underline tab bar,
SVG readiness ring, grouped quick actions, branded progress bars and animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-16 12:38:28 +01:00
parent 079468d2ca
commit 86fa542371
2 changed files with 718 additions and 668 deletions

View File

@ -87,6 +87,8 @@ import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { AnimatedCard } from '@/components/shared/animated-container'
import { motion } from 'motion/react'
// ── Status & type config maps ──────────────────────────────────────────────
const roundStatusConfig = {
@ -313,18 +315,21 @@ export default function RoundDetailPage() {
if (isLoading) {
return (
<div className="space-y-6">
{/* Header skeleton — dark gradient placeholder */}
<div className="rounded-xl bg-gradient-to-r from-[#053d57]/20 to-[#0a5a7c]/20 p-6 animate-pulse">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8 rounded bg-white/20" />
<div className="space-y-2">
<Skeleton className="h-7 w-64" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-7 w-64 bg-white/20" />
<Skeleton className="h-4 w-40 bg-white/20" />
</div>
</div>
</div>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-24" />)}
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-28 rounded-lg" />)}
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
)
}
@ -398,18 +403,24 @@ export default function RoundDetailPage() {
return (
<div className="space-y-6">
{/* ===== HEADER ===== */}
{/* ===== HEADER — Dark Blue gradient banner ===== */}
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="rounded-xl bg-gradient-to-r from-[#053d57] to-[#0a5a7c] p-5 sm:p-6 text-white shadow-lg"
>
<div className="flex flex-col gap-4 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" aria-label="Back to rounds">
<Link href={'/admin/rounds' as Route} className="mt-0.5 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label="Back to rounds">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl font-bold tracking-tight truncate">{round.name}</h1>
<Badge variant="secondary" className={cn('text-xs shrink-0', typeCfg.color)}>
<Badge variant="secondary" className="text-xs shrink-0 bg-white/15 text-white border-white/20 hover:bg-white/20">
{typeCfg.label}
</Badge>
@ -419,8 +430,7 @@ export default function RoundDetailPage() {
<button
className={cn(
'inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-full transition-colors shrink-0',
statusCfg.bgClass,
'hover:opacity-80',
'bg-white/15 text-white hover:bg-white/25',
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dotClass)} />
@ -475,14 +485,14 @@ export default function RoundDetailPage() {
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm text-muted-foreground mt-0.5">{typeCfg.description}</p>
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{hasChanges && (
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending} className="bg-white text-[#053d57] hover:bg-white/90">
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
) : (
@ -492,26 +502,28 @@ export default function RoundDetailPage() {
</Button>
)}
<Link href={poolLink}>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="border-white/30 text-white hover:bg-white/10 hover:text-white">
<Layers className="h-4 w-4 mr-1.5" />
Project Pool
</Button>
</Link>
</div>
</div>
</motion.div>
{/* ===== STATS BAR ===== */}
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{/* Projects */}
<Card>
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Projects</span>
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-[#557f8c]/10 p-1.5">
<Layers className="h-4 w-4 text-[#557f8c]" />
</div>
<span className="text-sm font-medium text-muted-foreground">Projects</span>
</div>
<p className="text-2xl font-bold mt-1">{projectCount}</p>
<p className="text-3xl font-bold mt-2">{projectCount}</p>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{Object.entries(stateCounts).map(([state, count]) => (
<span key={state} className="text-[10px] text-muted-foreground">
@ -521,13 +533,17 @@ export default function RoundDetailPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) */}
<Card>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 mb-1" data-jury-select>
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
<div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Jury</span>
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div>
{juryGroups && juryGroups.length > 0 ? (
<Select
@ -554,28 +570,32 @@ export default function RoundDetailPage() {
</Select>
) : juryGroup ? (
<>
<p className="text-2xl font-bold mt-1">{juryMemberCount}</p>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No jury groups yet</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Window */}
<Card>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-emerald-50 p-1.5">
<CalendarDays className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Window</span>
</div>
<span className="text-sm font-medium text-muted-foreground">Window</span>
</div>
{round.windowOpenAt || round.windowCloseAt ? (
<>
<p className="text-sm font-bold mt-1">
<p className="text-sm font-bold mt-2">
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString()
: 'No start'}
@ -588,91 +608,110 @@ export default function RoundDetailPage() {
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No dates set</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advancement */}
<Card>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-amber-50 p-1.5">
<BarChart3 className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Advancement</span>
</div>
<span className="text-sm font-medium text-muted-foreground">Advancement</span>
</div>
{round.advancementRules && round.advancementRules.length > 0 ? (
<>
<p className="text-2xl font-bold mt-1">{round.advancementRules.length}</p>
<p className="text-3xl font-bold mt-2">{round.advancementRules.length}</p>
<p className="text-xs text-muted-foreground">
{round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
</p>
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">Admin selection</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* ===== TABS ===== */}
{/* ===== TABS — Underline style ===== */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="overview">
<Zap className="h-3.5 w-3.5 mr-1.5" />
Overview
</TabsTrigger>
<TabsTrigger value="projects">
<Layers className="h-3.5 w-3.5 mr-1.5" />
Projects
</TabsTrigger>
{isFiltering && (
<TabsTrigger value="filtering">
<Shield className="h-3.5 w-3.5 mr-1.5" />
Filtering
</TabsTrigger>
<div className="border-b overflow-x-auto">
<TabsList className="bg-transparent h-auto p-0 gap-0 w-full sm:w-auto">
{[
{ value: 'overview', label: 'Overview', icon: Zap },
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Submissions', icon: Clock },
{ value: 'awards', label: 'Awards', icon: Trophy },
].map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'relative rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium transition-all',
'data-[state=active]:border-b-[#de0f1e] data-[state=active]:text-[#053d57] data-[state=active]:font-semibold data-[state=active]:shadow-none',
'text-muted-foreground hover:text-foreground',
'bg-transparent data-[state=active]:bg-transparent',
)}
{isEvaluation && (
<TabsTrigger value="assignments">
<ClipboardList className="h-3.5 w-3.5 mr-1.5" />
Assignments
</TabsTrigger>
)}
<TabsTrigger value="config">
<Settings className="h-3.5 w-3.5 mr-1.5" />
Config
</TabsTrigger>
<TabsTrigger value="windows">
<Clock className="h-3.5 w-3.5 mr-1.5" />
Submissions
</TabsTrigger>
<TabsTrigger value="awards">
<Trophy className="h-3.5 w-3.5 mr-1.5" />
Awards
{roundAwards.length > 0 && (
>
<tab.icon className={cn('h-3.5 w-3.5 mr-1.5', activeTab === tab.value ? 'text-[#557f8c]' : '')} />
{tab.label}
{tab.value === 'awards' && roundAwards.length > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 text-[10px] px-1.5 bg-[#de0f1e] text-white">
{roundAwards.length}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</div>
{/* ═══════════ OVERVIEW TAB ═══════════ */}
<TabsContent value="overview" className="space-y-6">
{/* Readiness Checklist */}
{/* Readiness Checklist with Progress Ring */}
<AnimatedCard index={0}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* SVG Progress Ring */}
<div className="relative h-14 w-14 shrink-0">
<svg className="h-14 w-14 -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="currentColor" strokeWidth="3" className="text-muted/30" />
<circle
cx="28" cy="28" r="24" fill="none"
strokeWidth="3" strokeLinecap="round"
stroke={readyCount === readinessItems.length ? '#10b981' : '#de0f1e'}
strokeDasharray={`${(readyCount / readinessItems.length) * 150.8} 150.8`}
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold">
{readyCount}/{readinessItems.length}
</span>
</div>
<div>
<CardTitle className="text-base">Readiness Checklist</CardTitle>
<CardTitle className="text-base">Launch Readiness</CardTitle>
<CardDescription>
{readyCount}/{readinessItems.length} items ready
{readyCount === readinessItems.length
? 'All checks passed — ready to go'
: `${readinessItems.length - readyCount} item(s) remaining`}
</CardDescription>
</div>
</div>
<Badge
variant={readyCount === readinessItems.length ? 'default' : 'secondary'}
className={cn(
@ -696,14 +735,14 @@ export default function RoundDetailPage() {
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium', item.ready && 'text-muted-foreground')}>
<p className={cn('text-sm font-medium', item.ready && 'text-muted-foreground line-through opacity-60')}>
{item.label}
</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
{item.action && (
<Link href={item.action}>
<Button variant="outline" size="sm" className="shrink-0 text-xs">
<Button size="sm" className="shrink-0 text-xs bg-[#de0f1e] hover:bg-[#c00d1a] text-white">
{item.actionLabel}
</Button>
</Link>
@ -713,20 +752,25 @@ export default function RoundDetailPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Quick Actions */}
{/* Quick Actions — Grouped & styled */}
<AnimatedCard index={1}>
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>
<CardDescription>Common operations for this round</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{/* Round Control Group */}
{(status === 'ROUND_DRAFT' || status === 'ROUND_ACTIVE' || status === 'ROUND_CLOSED') && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Round Control</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* Status transitions */}
{status === 'ROUND_DRAFT' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left">
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<Play className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Activate Round</p>
@ -756,7 +800,7 @@ export default function RoundDetailPage() {
{status === 'ROUND_ACTIVE' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left">
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<Square className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close Round</p>
@ -791,7 +835,7 @@ export default function RoundDetailPage() {
{status === 'ROUND_CLOSED' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50">
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-amber-500 bg-amber-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<RotateCcw className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Reopen Round</p>
@ -817,11 +861,17 @@ export default function RoundDetailPage() {
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)}
{/* Assign projects */}
{/* Project Management Group */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
<Layers className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
@ -831,20 +881,34 @@ export default function RoundDetailPage() {
</button>
</Link>
{/* Filtering specific */}
{isFiltering && (
<button
onClick={() => setActiveTab('filtering')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Shield className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<BarChart3 className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Run AI Filtering</p>
<p className="text-sm font-medium">Manage Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Screen projects with AI and manual review
View, filter, and transition project states
</p>
</div>
</button>
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 bg-emerald-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
</p>
</div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
</button>
)}
{/* Jury assignment for evaluation/filtering */}
@ -854,7 +918,7 @@ export default function RoundDetailPage() {
const el = document.querySelector('[data-jury-select]')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50"
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-amber-500 bg-amber-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<UserPlus className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div>
@ -870,9 +934,9 @@ export default function RoundDetailPage() {
{isEvaluation && (
<button
onClick={() => setActiveTab('assignments')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<ClipboardList className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Manage Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">
@ -881,29 +945,35 @@ export default function RoundDetailPage() {
</div>
</button>
)}
</div>
</div>
{/* View projects */}
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
>
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
{/* AI Tools Group */}
{((isFiltering || isEvaluation) && projectCount > 0) && (
<div>
<p className="text-sm font-medium">Manage Projects</p>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">AI Tools</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{isFiltering && (
<button
onClick={() => setActiveTab('filtering')}
className="flex items-start gap-3 p-4 rounded-lg border bg-gradient-to-br from-purple-50/50 to-blue-50/50 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Shield className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Run AI Filtering</p>
<p className="text-xs text-muted-foreground mt-0.5">
View, filter, and transition project states
Screen projects with AI and manual review
</p>
</div>
</button>
)}
{/* AI Shortlist Recommendations */}
{(isEvaluation || isFiltering) && projectCount > 0 && (
<button
onClick={() => setShortlistDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-purple-200 bg-purple-50/50"
className="flex items-start gap-3 p-4 rounded-lg border bg-gradient-to-br from-purple-50/50 to-blue-50/50 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
disabled={shortlistMutation.isPending}
>
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<Zap className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
@ -913,26 +983,12 @@ export default function RoundDetailPage() {
</p>
</div>
</button>
)}
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-green-200 bg-green-50/50"
>
<ArrowRight className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
@ -992,53 +1048,39 @@ export default function RoundDetailPage() {
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-sm">Round Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type</span>
<Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className="font-medium">{statusCfg.label}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sort Order</span>
<span className="font-medium font-mono">{round.sortOrder}</span>
</div>
{round.purposeKey && (
<div className="flex justify-between">
<span className="text-muted-foreground">Purpose</span>
<span className="font-medium">{round.purposeKey}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Jury Group</span>
<span className="font-medium">
{juryGroup ? juryGroup.name : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opens</span>
<span className="font-medium">
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Closes</span>
<span className="font-medium">
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
</span>
<CardContent className="space-y-0 text-sm">
{[
{ label: 'Type', value: <Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge> },
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
{ label: 'Sort Order', value: <span className="font-medium font-mono">{round.sortOrder}</span> },
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
].map((row, i) => (
<div key={row.label} className={cn('flex justify-between items-center py-2.5', i > 0 && 'border-t border-dotted border-muted')}>
<span className="text-muted-foreground">{row.label}</span>
{row.value}
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Project Breakdown</CardTitle>
{projectCount > 0 && (
<span className="text-xs font-mono text-muted-foreground">{projectCount} total</span>
)}
</div>
</CardHeader>
<CardContent>
{projectCount === 0 ? (
@ -1046,20 +1088,20 @@ export default function RoundDetailPage() {
No projects assigned yet
</p>
) : (
<div className="space-y-2">
<div className="space-y-3">
{['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => {
const count = stateCounts[state] || 0
if (count === 0) return null
const pct = ((count / projectCount) * 100).toFixed(0)
return (
<div key={state}>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground capitalize">{state.toLowerCase().replace('_', ' ')}</span>
<span className="font-medium">{count} ({pct}%)</span>
<div className="flex justify-between text-xs mb-1.5">
<span className="text-muted-foreground capitalize font-medium">{state.toLowerCase().replace('_', ' ')}</span>
<span className="font-bold tabular-nums">{count} <span className="font-normal text-muted-foreground">({pct}%)</span></span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', stateColors[state])}
className={cn('h-full rounded-full transition-all duration-500', stateColors[state])}
style={{ width: `${pct}%` }}
/>
</div>
@ -1070,6 +1112,7 @@ export default function RoundDetailPage() {
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</TabsContent>
@ -1170,12 +1213,12 @@ export default function RoundDetailPage() {
<TabsContent value="config" className="space-y-6">
{/* General Round Settings */}
<Card>
<CardHeader>
<CardHeader className="border-b">
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center justify-between">
<CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
@ -1193,7 +1236,7 @@ export default function RoundDetailPage() {
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
@ -1211,7 +1254,7 @@ export default function RoundDetailPage() {
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between p-4 rounded-md">
<div className="space-y-0.5">
<Label htmlFor="ai-parse-files" className="text-sm font-medium">
AI document parsing
@ -1229,7 +1272,7 @@ export default function RoundDetailPage() {
/>
</div>
<div className="border-t pt-4">
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label>
<p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round
@ -1316,10 +1359,12 @@ export default function RoundDetailPage() {
<Card>
<CardContent className="p-6">
{roundAwards.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No awards linked to this round</p>
<p className="text-xs mt-1">
<div className="text-center py-12 text-muted-foreground">
<div className="rounded-full bg-[#de0f1e]/10 p-4 w-fit mx-auto mb-4">
<Trophy className="h-8 w-8 text-[#de0f1e]/60" />
</div>
<p className="text-sm font-medium text-foreground">No Awards Linked</p>
<p className="text-xs mt-1 max-w-sm mx-auto">
Create an award and set this round as its evaluation round to see it here
</p>
</div>
@ -1336,7 +1381,7 @@ export default function RoundDetailPage() {
href={`/admin/awards/${award.id}` as Route}
className="block"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4 transition-all hover:bg-muted/50 hover:shadow-sm">
<div className="flex items-start justify-between gap-4 rounded-lg border border-l-4 border-l-[#de0f1e] p-4 transition-all hover:shadow-md hover:-translate-y-0.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{award.name}</h3>
@ -1411,7 +1456,10 @@ function RoundUnassignedQueue({ roundId }: { roundId: string }) {
{unassigned.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
className={cn(
'flex justify-between items-center p-3 border rounded-md hover:bg-muted/30 transition-colors',
(project.assignmentCount || 0) === 0 && 'border-l-4 border-l-red-500',
)}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
@ -1468,25 +1516,25 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
<div className="space-y-3 max-h-[350px] overflow-y-auto">
{workload.map((juror) => {
const pct = juror.completionRate
const barColor = pct === 100
? 'bg-emerald-500'
const barGradient = pct === 100
? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
: pct >= 50
? 'bg-blue-500'
? 'bg-gradient-to-r from-blue-400 to-blue-600'
: pct > 0
? 'bg-amber-500'
? 'bg-gradient-to-r from-amber-400 to-amber-600'
: 'bg-gray-300'
return (
<div key={juror.id} className="space-y-1">
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors">
<div className="flex justify-between text-xs">
<span className="font-medium truncate max-w-[60%]">{juror.name}</span>
<span className="text-muted-foreground shrink-0">
<span className="text-muted-foreground shrink-0 tabular-nums">
{juror.completed}/{juror.assigned} ({pct}%)
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', barColor)}
className={cn('h-full rounded-full transition-all duration-500', barGradient)}
style={{ width: `${pct}%` }}
/>
</div>
@ -1700,10 +1748,13 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
<span>Status</span>
<span />
</div>
{assignments.map((a: any) => (
{assignments.map((a: any, idx: number) => (
<div
key={a.id}
className="grid grid-cols-[1fr_1fr_100px_60px] gap-2 items-center px-3 py-2 rounded-md hover:bg-muted/30 text-sm"
className={cn(
'grid grid-cols-[1fr_1fr_100px_60px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
)}
>
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
@ -1996,8 +2047,8 @@ function AIRecommendationsDisplay({
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
>
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0',
colorClass,
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
)}>
{item.rank}
</span>

View File

@ -112,7 +112,6 @@ export async function activateRound(
data: { status: 'ROUND_ACTIVE' },
})
// Dual audit trail
await tx.decisionAuditLog.create({
data: {
eventType: 'round.activated',
@ -132,8 +131,11 @@ export async function activateRound(
},
})
return result
})
// Audit log outside transaction to avoid FK violations poisoning the tx
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ACTIVATE',
entityType: 'Round',
@ -141,9 +143,6 @@ export async function activateRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
@ -225,8 +224,11 @@ export async function closeRound(
},
})
return result
})
// Audit log outside transaction to avoid FK violations poisoning the tx
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_CLOSE',
entityType: 'Round',
@ -234,9 +236,6 @@ export async function closeRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
@ -296,8 +295,11 @@ export async function archiveRound(
},
})
return result
})
// Audit log outside transaction to avoid FK violations poisoning the tx
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_ARCHIVE',
entityType: 'Round',
@ -305,9 +307,6 @@ export async function archiveRound(
detailsJson: { name: round.name },
})
return result
})
return {
success: true,
round: { id: updated.id, status: updated.status },
@ -412,24 +411,24 @@ export async function reopenRound(
},
})
return {
updated,
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
}
})
// Audit log outside transaction to avoid FK violations poisoning the tx
await logAudit({
prisma: tx,
userId: actorId,
action: 'ROUND_REOPEN',
entityType: 'Round',
entityId: roundId,
detailsJson: {
name: round.name,
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
pausedRounds: result.pausedRounds,
},
})
return {
updated,
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
}
})
return {
success: true,
round: { id: result.updated.id, status: result.updated.status },
@ -527,25 +526,25 @@ export async function transitionProject(
},
})
return { prs, previousState: existing?.state ?? null }
})
// Audit log outside transaction to avoid FK violations poisoning the tx
await logAudit({
prisma: tx,
userId: actorId,
action: 'PROJECT_ROUND_TRANSITION',
entityType: 'ProjectRoundState',
entityId: prs.id,
detailsJson: { projectId, roundId, newState, previousState: existing?.state ?? null },
})
return prs
entityId: result.prs.id,
detailsJson: { projectId, roundId, newState, previousState: result.previousState },
})
return {
success: true,
projectRoundState: {
id: result.id,
projectId: result.projectId,
roundId: result.roundId,
state: result.state,
id: result.prs.id,
projectId: result.prs.projectId,
roundId: result.prs.roundId,
state: result.prs.state,
},
}
} catch (error) {