Admin dashboard & round management UX overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s

- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 17:14:00 +01:00
parent f7bc3b4dd2
commit f26ee3f076
51 changed files with 4530 additions and 6276 deletions

View File

@@ -13,24 +13,41 @@ import {
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import { formatDateOnly, truncate } from '@/lib/utils'
import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
type ProjectListCompactProps = {
projects: Array<{
id: string
title: string
teamName: string | null
country: string | null
competitionCategory: string | null
oceanIssue: string | null
logoKey: string | null
createdAt: Date
submittedAt: Date | null
status: string
}>
type BaseProject = {
id: string
title: string
teamName: string | null
country: string | null
competitionCategory: string | null
oceanIssue: string | null
logoKey: string | null
createdAt: Date
submittedAt: Date | null
status: string
}
export function ProjectListCompact({ projects }: ProjectListCompactProps) {
type ActiveProject = BaseProject & {
latestEvaluator: string | null
latestScore: number | null
evaluatedAt: Date | null
}
type ProjectListCompactProps = {
projects: BaseProject[]
activeProjects?: ActiveProject[]
mode?: 'recent' | 'active'
}
export function ProjectListCompact({
projects,
activeProjects,
mode = 'recent',
}: ProjectListCompactProps) {
const isActiveMode = mode === 'active' && activeProjects && activeProjects.length > 0
const displayProjects = isActiveMode ? activeProjects : projects
return (
<Card>
<CardHeader className="pb-3">
@@ -40,8 +57,12 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
<ClipboardList className="h-4 w-4 text-brand-blue" />
</div>
<div>
<CardTitle className="text-base">Recent Projects</CardTitle>
<CardDescription className="text-xs">Latest submissions</CardDescription>
<CardTitle className="text-base">
{isActiveMode ? 'Recently Active' : 'Recent Projects'}
</CardTitle>
<CardDescription className="text-xs">
{isActiveMode ? 'Latest evaluation activity' : 'Latest submissions'}
</CardDescription>
</div>
</div>
<Link
@@ -53,7 +74,7 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
</div>
</CardHeader>
<CardContent>
{projects.length === 0 ? (
{displayProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<ClipboardList className="h-7 w-7 text-muted-foreground/40" />
@@ -64,48 +85,69 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
</div>
) : (
<div className="divide-y">
{projects.map((project, idx) => (
<motion.div
key={project.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
>
<Link
href={`/admin/projects/${project.id}`}
className="block"
{displayProjects.map((project, idx) => {
const activeProject = isActiveMode ? (project as ActiveProject) : null
return (
<motion.div
key={project.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.15 + idx * 0.04 }}
>
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{truncate(project.title, 50)}
<Link
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{truncate(project.title, 50)}
</p>
{activeProject?.latestScore != null ? (
<span className="shrink-0 text-xs font-semibold tabular-nums text-brand-blue">
{activeProject.latestScore.toFixed(1)}/10
</span>
) : (
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{isActiveMode && activeProject ? (
<>
{activeProject.latestEvaluator && (
<span>{activeProject.latestEvaluator}</span>
)}
{activeProject.evaluatedAt && (
<span> &middot; {formatRelativeTime(activeProject.evaluatedAt)}</span>
)}
</>
) : (
[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')
)}
</p>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
size="sm"
className="shrink-0"
/>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
</div>
</div>
</Link>
</motion.div>
))}
</Link>
</motion.div>
)
})}
</div>
)}
</CardContent>