fix: separate main pipeline from award tracks on rounds page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m24s

Award-specific rounds (e.g. Spotlight on Africa) were mixed into the
main pipeline as a flat list, making them look like sequential steps
after Deliberation. Now they render in their own amber-tinted card
sections below the main pipeline, each with a header showing award
name, pool size, eligibility mode, and status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 15:55:17 +01:00
parent b7905a82e1
commit 461551b489

View File

@@ -41,7 +41,6 @@ import {
FileBox,
Save,
Loader2,
Award,
Trophy,
ArrowRight,
} from 'lucide-react'
@@ -151,27 +150,42 @@ export default function RoundsPage() {
onError: (err) => toast.error(err.message),
})
const rounds = useMemo(() => {
// Split rounds into main pipeline (no specialAwardId) and award tracks
const mainRounds = useMemo(() => {
const all = (compDetail?.rounds ?? []) as RoundWithStats[]
return filterType === 'all' ? all : all.filter((r) => r.roundType === filterType)
const main = all.filter((r) => !r.specialAwardId)
return filterType === 'all' ? main : main.filter((r) => r.roundType === filterType)
}, [compDetail?.rounds, filterType])
// Group awards by their evaluationRoundId
const awardsByRound = useMemo(() => {
const map = new Map<string, SpecialAwardItem[]>()
for (const award of (awards ?? []) as SpecialAwardItem[]) {
if (award.evaluationRoundId) {
const existing = map.get(award.evaluationRoundId) ?? []
existing.push(award)
map.set(award.evaluationRoundId, existing)
// Group award-track rounds by their specialAwardId, paired with the award metadata
const awardTrackGroups = useMemo(() => {
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const awardRounds = allRounds.filter((r) => r.specialAwardId)
const groups = new Map<string, { award: SpecialAwardItem; rounds: RoundWithStats[] }>()
for (const round of awardRounds) {
const awardId = round.specialAwardId!
if (!groups.has(awardId)) {
const award = ((awards ?? []) as SpecialAwardItem[]).find((a) => a.id === awardId)
if (!award) continue
groups.set(awardId, { award, rounds: [] })
}
groups.get(awardId)!.rounds.push(round)
}
return map
}, [awards])
return Array.from(groups.values())
}, [compDetail?.rounds, awards])
const floatingAwards = useMemo(() => {
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId)
}, [awards])
// Awards that have no evaluationRoundId AND no rounds linked via specialAwardId
const awardIdsWithRounds = new Set(
((compDetail?.rounds ?? []) as RoundWithStats[])
.filter((r) => r.specialAwardId)
.map((r) => r.specialAwardId!)
)
return ((awards ?? []) as SpecialAwardItem[]).filter(
(a) => !a.evaluationRoundId && !awardIdsWithRounds.has(a.id)
)
}, [awards, compDetail?.rounds])
const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
@@ -271,8 +285,10 @@ export default function RoundsPage() {
const activeFilter = filterType !== 'all'
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
const allMainRounds = allRounds.filter((r) => !r.specialAwardId)
const awardRoundCount = allRounds.length - allMainRounds.length
const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0)
const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE')
const activeRound = allMainRounds.find((r) => r.status === 'ROUND_ACTIVE')
return (
<TooltipProvider delayDuration={200}>
@@ -313,7 +329,7 @@ export default function RoundsPage() {
</Tooltip>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span>{allRounds.filter((r) => !r.specialAwardId).length} rounds</span>
<span>{allMainRounds.length} rounds{awardRoundCount > 0 ? ` + ${awardRoundCount} award` : ''}</span>
<span className="text-muted-foreground/30">|</span>
<span>{totalProjects} projects</span>
<span className="text-muted-foreground/30">|</span>
@@ -330,12 +346,12 @@ export default function RoundsPage() {
</span>
</>
)}
{awards && awards.length > 0 && (
{awardTrackGroups.length > 0 && (
<>
<span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1">
<Award className="h-3.5 w-3.5" />
{awards.length} awards
<Trophy className="h-3.5 w-3.5" />
{awardTrackGroups.length} award {awardTrackGroups.length === 1 ? 'track' : 'tracks'}
</span>
</>
)}
@@ -389,7 +405,7 @@ export default function RoundsPage() {
</div>
{/* ── Pipeline View ───────────────────────────────────────────── */}
{rounds.length === 0 ? (
{mainRounds.length === 0 && awardTrackGroups.length === 0 ? (
<div className="py-16 text-center border-2 border-dashed rounded-lg">
<FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
@@ -397,142 +413,79 @@ export default function RoundsPage() {
</p>
</div>
) : (
<div className="space-y-6">
{/* ── Main Competition Pipeline ───────────────────────── */}
{mainRounds.length > 0 && (
<div className="relative">
{/* Main pipeline track */}
{rounds.map((round, index) => {
const isLast = index === rounds.length - 1
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
const projectCount = round._count.projectRoundStates
const assignmentCount = round._count.assignments
const roundAwards = awardsByRound.get(round.id) ?? []
{mainRounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
isLast={index === mainRounds.length - 1}
/>
))}
</div>
)}
{/* ── Award Track Sections ────────────────────────────── */}
{awardTrackGroups.map(({ award, rounds: awardRounds }) => {
const isExclusive = award.eligibilityMode === 'SEPARATE_POOL'
const eligible = award._count.eligibilities
const statusColor = AWARD_STATUS_COLORS[award.status] ?? 'text-gray-500'
return (
<div key={round.id} className="relative">
{/* Round row with pipeline connector */}
<div className="flex">
{/* Left: pipeline track */}
<div className="flex flex-col items-center shrink-0 w-10">
{/* Status dot */}
<Tooltip>
<TooltipTrigger asChild>
<div className="relative z-10 flex items-center justify-center">
<div
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: statusStyle.color }}
/>
{statusStyle.pulse && (
<div
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
style={{ backgroundColor: statusStyle.color }}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{statusStyle.label}
</TooltipContent>
</Tooltip>
{/* Connector line */}
{!isLast && (
<div className="w-px flex-1 min-h-[8px] bg-border" />
)}
</div>
{/* Right: round content + awards */}
<div className="flex-1 min-w-0 pb-2">
<div className="flex items-stretch gap-3">
{/* Round row */}
<Link
href={`/admin/rounds/${round.id}` as Route}
className="flex-1 min-w-0"
key={award.id}
className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
>
<div
className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
)}
style={{ borderLeftColor: typeColors.dot }}
>
{/* Round type indicator */}
{/* Award track header */}
<Link href={`/admin/awards/${award.id}` as Route}>
<div className="group flex items-center gap-3 px-4 py-3 border-b border-amber-200/60 hover:bg-amber-50/60 transition-colors cursor-pointer">
<div className="flex items-center justify-center h-8 w-8 rounded-full bg-amber-100 shrink-0">
<Trophy className="h-4 w-4 text-amber-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-[#053d57] group-hover:text-[#de0f1e] transition-colors truncate">
{award.name}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{eligible} projects</span>
<span className="text-muted-foreground/30">&middot;</span>
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
typeColors.text
'text-[10px] font-semibold uppercase tracking-wide px-1.5 py-px rounded',
isExclusive
? 'bg-red-100 text-red-600'
: 'bg-blue-100 text-blue-600'
)}>
{round.roundType.replace('_', ' ')}
{isExclusive ? 'Exclusive pool' : 'Parallel'}
</span>
{/* Round name */}
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
{round.name}
<span className="text-muted-foreground/30">&middot;</span>
<span className={statusColor}>
{award.status.replace('_', ' ')}
</span>
{/* Stats cluster */}
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{round.juryGroup && (
<span className="flex items-center gap-1 max-w-[120px]">
<Users className="h-3 w-3 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</span>
)}
<span className="flex items-center gap-1">
<FileBox className="h-3 w-3 shrink-0" />
{projectCount}
</span>
{assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} asgn</span>
)}
{(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums">
<Calendar className="h-3 w-3 shrink-0" />
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
</span>
)}
</div>
{/* Status badge (compact) */}
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
>
{statusStyle.label}
</Badge>
{/* Arrow */}
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div>
</Link>
{/* Awards branching off this round */}
{roundAwards.length > 0 && (
<div className="flex items-center gap-2 shrink-0">
{/* Connector dash */}
<div className="w-4 h-px bg-amber-300" />
{/* Award nodes */}
<div className="flex flex-col gap-1">
{roundAwards.map((award) => (
<AwardNode key={award.id} award={award} />
{/* Award track rounds */}
<div className="px-4 py-2">
{awardRounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
isLast={index === awardRounds.length - 1}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
})}
{/* Floating awards (no evaluationRoundId) */}
{/* Floating awards (no linked rounds) */}
{floatingAwards.length > 0 && (
<div className="mt-4 pt-4 border-t border-dashed">
<div className="pt-2 border-t border-dashed">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
Unlinked Awards
</p>
@@ -685,6 +638,110 @@ export default function RoundsPage() {
)
}
// ─── Round Row ───────────────────────────────────────────────────────────────
function RoundRow({ round, isLast }: { round: RoundWithStats; isLast: boolean }) {
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT
const projectCount = round._count.projectRoundStates
const assignmentCount = round._count.assignments
return (
<div className="flex">
{/* Left: pipeline track */}
<div className="flex flex-col items-center shrink-0 w-10">
<Tooltip>
<TooltipTrigger asChild>
<div className="relative z-10 flex items-center justify-center">
<div
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: statusStyle.color }}
/>
{statusStyle.pulse && (
<div
className="absolute h-3.5 w-3.5 rounded-full animate-ping opacity-40"
style={{ backgroundColor: statusStyle.color }}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{statusStyle.label}
</TooltipContent>
</Tooltip>
{!isLast && (
<div className="w-px flex-1 min-h-[8px] bg-border" />
)}
</div>
{/* Right: round content */}
<div className="flex-1 min-w-0 pb-2">
<Link
href={`/admin/rounds/${round.id}` as Route}
className="block"
>
<div
className={cn(
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all',
'bg-white hover:bg-gray-50/80 hover:shadow-sm',
)}
style={{ borderLeftColor: typeColors.dot }}
>
<span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
typeColors.text
)}>
{round.roundType.replace('_', ' ')}
</span>
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1">
{round.name}
</span>
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground shrink-0">
{round.juryGroup && (
<span className="flex items-center gap-1 max-w-[120px]">
<Users className="h-3 w-3 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</span>
)}
<span className="flex items-center gap-1">
<FileBox className="h-3 w-3 shrink-0" />
{projectCount}
</span>
{assignmentCount > 0 && (
<span className="tabular-nums">{assignmentCount} asgn</span>
)}
{(round.windowOpenAt || round.windowCloseAt) && (
<span className="flex items-center gap-1 tabular-nums">
<Calendar className="h-3 w-3 shrink-0" />
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
{round.windowOpenAt && round.windowCloseAt ? ' \u2013 ' : ''}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
: ''}
</span>
)}
</div>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-5 font-medium shrink-0 hidden md:inline-flex"
style={{ color: statusStyle.color, borderColor: statusStyle.color + '40' }}
>
{statusStyle.label}
</Badge>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
</div>
</Link>
</div>
</div>
)
}
// ─── Award Node ──────────────────────────────────────────────────────────────
function AwardNode({ award }: { award: SpecialAwardItem }) {