From 86fa542371222e821f2663c0490f0478b8c9f172 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 12:38:28 +0100 Subject: [PATCH] Fix round reopen bug + redesign round detail page UI 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 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 1281 +++++++++-------- src/server/services/round-engine.ts | 105 +- 2 files changed, 718 insertions(+), 668 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index d3efe23..81b1557 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -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 (
-
- -
- - + {/* Header skeleton — dark gradient placeholder */} +
+
+ +
+ + +
- {[1, 2, 3, 4].map((i) => )} + {[1, 2, 3, 4].map((i) => )}
- +
) } @@ -398,541 +403,592 @@ export default function RoundDetailPage() { return (
- {/* ===== HEADER ===== */} -
-
- - - -
-
-

{round.name}

- - {typeCfg.label} - + {/* ===== HEADER — Dark Blue gradient banner ===== */} + +
+
+ + + +
+
+

{round.name}

+ + {typeCfg.label} + - {/* Status dropdown */} - - - - - - {status === 'ROUND_DRAFT' && ( - activateMutation.mutate({ roundId })} - disabled={isTransitioning} + {/* Status dropdown */} + + + + + + {status === 'ROUND_DRAFT' && ( reopenMutation.mutate({ roundId })} + onClick={() => activateMutation.mutate({ roundId })} disabled={isTransitioning} > - Reopen Round + Activate Round - + )} + {status === 'ROUND_ACTIVE' && ( archiveMutation.mutate({ roundId })} + onClick={() => closeMutation.mutate({ roundId })} disabled={isTransitioning} > - - Archive Round + + Close Round - - )} - {isTransitioning && ( -
- - Updating... -
- )} -
-
+ )} + {status === 'ROUND_CLOSED' && ( + <> + reopenMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Reopen Round + + + archiveMutation.mutate({ roundId })} + disabled={isTransitioning} + > + + Archive Round + + + )} + {isTransitioning && ( +
+ + Updating... +
+ )} +
+
+
+

{typeCfg.description}

-

{typeCfg.description}

+
+ + {/* Action buttons */} +
+ {hasChanges && ( + + )} + + +
+
- {/* Action buttons */} -
- {hasChanges && ( - - )} - - - -
-
- - {/* ===== STATS BAR ===== */} + {/* ===== STATS BAR — Accent-bordered cards ===== */}
{/* Projects */} - - -
-
- - Projects -
-
-

{projectCount}

-
- {Object.entries(stateCounts).map(([state, count]) => ( - - {String(count)} {state.toLowerCase().replace('_', ' ')} - - ))} -
-
-
- - {/* Jury (with inline group selector) */} - - -
- - Jury -
- {juryGroups && juryGroups.length > 0 ? ( - - ) : juryGroup ? ( - <> -

{juryMemberCount}

-

{juryGroup.name}

- - ) : ( - <> -

-

No jury groups yet

- - )} -
-
- - {/* Window */} - - -
- - Window -
- {round.windowOpenAt || round.windowCloseAt ? ( - <> -

- {round.windowOpenAt - ? new Date(round.windowOpenAt).toLocaleDateString() - : 'No start'} -

-

- {round.windowCloseAt - ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}` - : 'No deadline'} -

- - ) : ( - <> -

-

No dates set

- - )} -
-
- - {/* Advancement */} - - -
- - Advancement -
- {round.advancementRules && round.advancementRules.length > 0 ? ( - <> -

{round.advancementRules.length}

-

- {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')} -

- - ) : ( - <> -

-

Admin selection

- - )} -
-
-
- - {/* ===== TABS ===== */} - - - - - Overview - - - - Projects - - {isFiltering && ( - - - Filtering - - )} - {isEvaluation && ( - - - Assignments - - )} - - - Config - - - - Submissions - - - - Awards - {roundAwards.length > 0 && ( - - {roundAwards.length} - - )} - - - - {/* ═══════════ OVERVIEW TAB ═══════════ */} - - {/* Readiness Checklist */} - - -
-
- Readiness Checklist - - {readyCount}/{readinessItems.length} items ready - + + + +
+
+
- - {readyCount === readinessItems.length ? 'Ready' : 'Incomplete'} - + Projects
- - -
- {readinessItems.map((item) => ( -
- {item.ready ? ( - - ) : ( - - )} -
-

- {item.label} -

-

{item.detail}

-
- {item.action && ( - - - - )} -
+

{projectCount}

+
+ {Object.entries(stateCounts).map(([state, count]) => ( + + {String(count)} {state.toLowerCase().replace('_', ' ')} + ))}
+ - {/* Quick Actions */} - - - Quick Actions - Common operations for this round - - -
- {/* Status transitions */} - {status === 'ROUND_DRAFT' && ( - - - - - - - Activate this round? - - The round will go live. Projects can be processed and jury members will be able to see their assignments. - - - - Cancel - activateMutation.mutate({ roundId })}> - Activate - - - - - )} - - {status === 'ROUND_ACTIVE' && ( - - - - - - - Close this round? - - No further changes will be accepted. You can reactivate later if needed. - {projectCount > 0 && ( - - {projectCount} projects are currently in this round. - - )} - - - - Cancel - closeMutation.mutate({ roundId })}> - Close Round - - - - - )} - - {status === 'ROUND_CLOSED' && ( - - - - - - - Reopen this round? - - The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically. - - - - Cancel - reopenMutation.mutate({ roundId })}> - Reopen - - - - - )} - - {/* Assign projects */} - - - - - {/* Filtering specific */} - {isFiltering && ( - - )} - - {/* Jury assignment for evaluation/filtering */} - {(isEvaluation || isFiltering) && !juryGroup && ( - - )} - - {/* Evaluation: manage assignments */} - {isEvaluation && ( - - )} - - {/* View projects */} - - - {/* AI Shortlist Recommendations */} - {(isEvaluation || isFiltering) && projectCount > 0 && ( - - )} - - {/* Advance projects (shown when PASSED > 0) */} - {passedCount > 0 && ( - - )} + {/* Jury (with inline group selector) */} + + + +
+
+ +
+ Jury
+ {juryGroups && juryGroups.length > 0 ? ( + + ) : juryGroup ? ( + <> +

{juryMemberCount}

+

{juryGroup.name}

+ + ) : ( + <> +

+

No jury groups yet

+ + )}
+
+ + {/* Window */} + + + +
+
+ +
+ Window +
+ {round.windowOpenAt || round.windowCloseAt ? ( + <> +

+ {round.windowOpenAt + ? new Date(round.windowOpenAt).toLocaleDateString() + : 'No start'} +

+

+ {round.windowCloseAt + ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}` + : 'No deadline'} +

+ + ) : ( + <> +

+

No dates set

+ + )} +
+
+
+ + {/* Advancement */} + + + +
+
+ +
+ Advancement +
+ {round.advancementRules && round.advancementRules.length > 0 ? ( + <> +

{round.advancementRules.length}

+

+ {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')} +

+ + ) : ( + <> +

+

Admin selection

+ + )} +
+
+
+
+ + {/* ===== TABS — Underline style ===== */} + +
+ + {[ + { 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) => ( + + + {tab.label} + {tab.value === 'awards' && roundAwards.length > 0 && ( + + {roundAwards.length} + + )} + + ))} + +
+ + {/* ═══════════ OVERVIEW TAB ═══════════ */} + + {/* Readiness Checklist with Progress Ring */} + + + +
+
+ {/* SVG Progress Ring */} +
+ + + + + + {readyCount}/{readinessItems.length} + +
+
+ Launch Readiness + + {readyCount === readinessItems.length + ? 'All checks passed — ready to go' + : `${readinessItems.length - readyCount} item(s) remaining`} + +
+
+ + {readyCount === readinessItems.length ? 'Ready' : 'Incomplete'} + +
+
+ +
+ {readinessItems.map((item) => ( +
+ {item.ready ? ( + + ) : ( + + )} +
+

+ {item.label} +

+

{item.detail}

+
+ {item.action && ( + + + + )} +
+ ))} +
+
+
+
+ + {/* Quick Actions — Grouped & styled */} + + + + Quick Actions + Common operations for this round + + + {/* Round Control Group */} + {(status === 'ROUND_DRAFT' || status === 'ROUND_ACTIVE' || status === 'ROUND_CLOSED') && ( +
+

Round Control

+
+ {status === 'ROUND_DRAFT' && ( + + + + + + + Activate this round? + + The round will go live. Projects can be processed and jury members will be able to see their assignments. + + + + Cancel + activateMutation.mutate({ roundId })}> + Activate + + + + + )} + + {status === 'ROUND_ACTIVE' && ( + + + + + + + Close this round? + + No further changes will be accepted. You can reactivate later if needed. + {projectCount > 0 && ( + + {projectCount} projects are currently in this round. + + )} + + + + Cancel + closeMutation.mutate({ roundId })}> + Close Round + + + + + )} + + {status === 'ROUND_CLOSED' && ( + + + + + + + Reopen this round? + + The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically. + + + + Cancel + reopenMutation.mutate({ roundId })}> + Reopen + + + + + )} +
+
+ )} + + {/* Project Management Group */} +
+

Project Management

+
+ + + + + + + {/* Advance projects (shown when PASSED > 0) */} + {passedCount > 0 && ( + + )} + + {/* Jury assignment for evaluation/filtering */} + {(isEvaluation || isFiltering) && !juryGroup && ( + + )} + + {/* Evaluation: manage assignments */} + {isEvaluation && ( + + )} +
+
+ + {/* AI Tools Group */} + {((isFiltering || isEvaluation) && projectCount > 0) && ( +
+

AI Tools

+
+ {isFiltering && ( + + )} + + +
+
+ )} +
+
+
{/* Advance Projects Dialog */} - - - Round Details - - -
- Type - {typeCfg.label} -
-
- Status - {statusCfg.label} -
-
- Sort Order - {round.sortOrder} -
- {round.purposeKey && ( -
- Purpose - {round.purposeKey} -
- )} -
- Jury Group - - {juryGroup ? juryGroup.name : '\u2014'} - -
-
- Opens - - {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'} - -
-
- Closes - - {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'} - -
-
-
+ + + + Round Details + + + {[ + { label: 'Type', value: {typeCfg.label} }, + { label: 'Status', value: {statusCfg.label} }, + { label: 'Sort Order', value: {round.sortOrder} }, + ...(round.purposeKey ? [{ label: 'Purpose', value: {round.purposeKey} }] : []), + { label: 'Jury Group', value: {juryGroup ? juryGroup.name : '\u2014'} }, + { label: 'Opens', value: {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'} }, + { label: 'Closes', value: {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'} }, + ].map((row, i) => ( +
0 && 'border-t border-dotted border-muted')}> + {row.label} + {row.value} +
+ ))} +
+
+
- - - Project Breakdown - - - {projectCount === 0 ? ( -

- No projects assigned yet -

- ) : ( -
- {['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 ( -
-
- {state.toLowerCase().replace('_', ' ')} - {count} ({pct}%) -
-
-
-
-
- ) - })} + + + +
+ Project Breakdown + {projectCount > 0 && ( + {projectCount} total + )}
- )} - -
+ + + {projectCount === 0 ? ( +

+ No projects assigned yet +

+ ) : ( +
+ {['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 ( +
+
+ {state.toLowerCase().replace('_', ' ')} + {count} ({pct}%) +
+
+
+
+
+ ) + })} +
+ )} + + +
@@ -1170,12 +1213,12 @@ export default function RoundDetailPage() { {/* General Round Settings */} - + General Settings Settings that apply to this round regardless of type - -
+ +
-
+
-
+
-
+

Target number of projects per category to advance from this round @@ -1316,10 +1359,12 @@ export default function RoundDetailPage() { {roundAwards.length === 0 ? ( -

- -

No awards linked to this round

-

+

+
+ +
+

No Awards Linked

+

Create an award and set this round as its evaluation round to see it here

@@ -1336,7 +1381,7 @@ export default function RoundDetailPage() { href={`/admin/awards/${award.id}` as Route} className="block" > -
+

{award.name}

@@ -1411,7 +1456,10 @@ function RoundUnassignedQueue({ roundId }: { roundId: string }) { {unassigned.map((project: any) => (

{project.title}

@@ -1468,25 +1516,25 @@ function JuryProgressTable({ roundId }: { roundId: string }) {
{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 ( -
+
{juror.name} - + {juror.completed}/{juror.assigned} ({pct}%)
-
+
@@ -1700,10 +1748,13 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) { Status
- {assignments.map((a: any) => ( + {assignments.map((a: any, idx: number) => (
{a.user?.name || a.user?.email || 'Unknown'} {a.project?.title || 'Unknown'} @@ -1996,8 +2047,8 @@ function AIRecommendationsDisplay({ className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors" > {item.rank} diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 7809c7c..e2693d5 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -112,7 +112,6 @@ export async function activateRound( data: { status: 'ROUND_ACTIVE' }, }) - // Dual audit trail await tx.decisionAuditLog.create({ data: { eventType: 'round.activated', @@ -132,18 +131,18 @@ export async function activateRound( }, }) - await logAudit({ - prisma: tx, - userId: actorId, - action: 'ROUND_ACTIVATE', - entityType: 'Round', - entityId: roundId, - detailsJson: { name: round.name, roundType: round.roundType }, - }) - return result }) + // Audit log outside transaction to avoid FK violations poisoning the tx + await logAudit({ + userId: actorId, + action: 'ROUND_ACTIVATE', + entityType: 'Round', + entityId: roundId, + detailsJson: { name: round.name, roundType: round.roundType }, + }) + return { success: true, round: { id: updated.id, status: updated.status }, @@ -225,18 +224,18 @@ export async function closeRound( }, }) - await logAudit({ - prisma: tx, - userId: actorId, - action: 'ROUND_CLOSE', - entityType: 'Round', - entityId: roundId, - detailsJson: { name: round.name, roundType: round.roundType }, - }) - return result }) + // Audit log outside transaction to avoid FK violations poisoning the tx + await logAudit({ + userId: actorId, + action: 'ROUND_CLOSE', + entityType: 'Round', + entityId: roundId, + detailsJson: { name: round.name, roundType: round.roundType }, + }) + return { success: true, round: { id: updated.id, status: updated.status }, @@ -296,18 +295,18 @@ export async function archiveRound( }, }) - await logAudit({ - prisma: tx, - userId: actorId, - action: 'ROUND_ARCHIVE', - entityType: 'Round', - entityId: roundId, - detailsJson: { name: round.name }, - }) - return result }) + // Audit log outside transaction to avoid FK violations poisoning the tx + await logAudit({ + userId: actorId, + action: 'ROUND_ARCHIVE', + entityType: 'Round', + entityId: roundId, + detailsJson: { name: round.name }, + }) + return { success: true, round: { id: updated.id, status: updated.status }, @@ -412,24 +411,24 @@ export async function reopenRound( }, }) - await logAudit({ - prisma: tx, - userId: actorId, - action: 'ROUND_REOPEN', - entityType: 'Round', - entityId: roundId, - detailsJson: { - name: round.name, - pausedRounds: subsequentActiveRounds.map((r: any) => r.name), - }, - }) - return { updated, pausedRounds: subsequentActiveRounds.map((r: any) => r.name), } }) + // Audit log outside transaction to avoid FK violations poisoning the tx + await logAudit({ + userId: actorId, + action: 'ROUND_REOPEN', + entityType: 'Round', + entityId: roundId, + detailsJson: { + name: round.name, + pausedRounds: result.pausedRounds, + }, + }) + return { success: true, round: { id: result.updated.id, status: result.updated.status }, @@ -527,25 +526,25 @@ export async function transitionProject( }, }) - 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, previousState: existing?.state ?? null } + }) - return prs + // Audit log outside transaction to avoid FK violations poisoning the tx + await logAudit({ + userId: actorId, + action: 'PROJECT_ROUND_TRANSITION', + entityType: 'ProjectRoundState', + 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) {