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

View File

@ -112,7 +112,6 @@ export async function activateRound(
data: { status: 'ROUND_ACTIVE' }, data: { status: 'ROUND_ACTIVE' },
}) })
// Dual audit trail
await tx.decisionAuditLog.create({ await tx.decisionAuditLog.create({
data: { data: {
eventType: 'round.activated', 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({ await logAudit({
prisma: tx,
userId: actorId, userId: actorId,
action: 'ROUND_ACTIVATE', action: 'ROUND_ACTIVATE',
entityType: 'Round', entityType: 'Round',
@ -141,9 +143,6 @@ export async function activateRound(
detailsJson: { name: round.name, roundType: round.roundType }, detailsJson: { name: round.name, roundType: round.roundType },
}) })
return result
})
return { return {
success: true, success: true,
round: { id: updated.id, status: updated.status }, 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({ await logAudit({
prisma: tx,
userId: actorId, userId: actorId,
action: 'ROUND_CLOSE', action: 'ROUND_CLOSE',
entityType: 'Round', entityType: 'Round',
@ -234,9 +236,6 @@ export async function closeRound(
detailsJson: { name: round.name, roundType: round.roundType }, detailsJson: { name: round.name, roundType: round.roundType },
}) })
return result
})
return { return {
success: true, success: true,
round: { id: updated.id, status: updated.status }, 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({ await logAudit({
prisma: tx,
userId: actorId, userId: actorId,
action: 'ROUND_ARCHIVE', action: 'ROUND_ARCHIVE',
entityType: 'Round', entityType: 'Round',
@ -305,9 +307,6 @@ export async function archiveRound(
detailsJson: { name: round.name }, detailsJson: { name: round.name },
}) })
return result
})
return { return {
success: true, success: true,
round: { id: updated.id, status: updated.status }, 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({ await logAudit({
prisma: tx,
userId: actorId, userId: actorId,
action: 'ROUND_REOPEN', action: 'ROUND_REOPEN',
entityType: 'Round', entityType: 'Round',
entityId: roundId, entityId: roundId,
detailsJson: { detailsJson: {
name: round.name, name: round.name,
pausedRounds: subsequentActiveRounds.map((r: any) => r.name), pausedRounds: result.pausedRounds,
}, },
}) })
return {
updated,
pausedRounds: subsequentActiveRounds.map((r: any) => r.name),
}
})
return { return {
success: true, success: true,
round: { id: result.updated.id, status: result.updated.status }, 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({ await logAudit({
prisma: tx,
userId: actorId, userId: actorId,
action: 'PROJECT_ROUND_TRANSITION', action: 'PROJECT_ROUND_TRANSITION',
entityType: 'ProjectRoundState', entityType: 'ProjectRoundState',
entityId: prs.id, entityId: result.prs.id,
detailsJson: { projectId, roundId, newState, previousState: existing?.state ?? null }, detailsJson: { projectId, roundId, newState, previousState: result.previousState },
})
return prs
}) })
return { return {
success: true, success: true,
projectRoundState: { projectRoundState: {
id: result.id, id: result.prs.id,
projectId: result.projectId, projectId: result.prs.projectId,
roundId: result.roundId, roundId: result.prs.roundId,
state: result.state, state: result.prs.state,
}, },
} }
} catch (error) { } catch (error) {