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, FileBox,
Save, Save,
Loader2, Loader2,
Award,
Trophy, Trophy,
ArrowRight, ArrowRight,
} from 'lucide-react' } from 'lucide-react'
@@ -151,27 +150,42 @@ export default function RoundsPage() {
onError: (err) => toast.error(err.message), 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[] 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]) }, [compDetail?.rounds, filterType])
// Group awards by their evaluationRoundId // Group award-track rounds by their specialAwardId, paired with the award metadata
const awardsByRound = useMemo(() => { const awardTrackGroups = useMemo(() => {
const map = new Map<string, SpecialAwardItem[]>() const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[]
for (const award of (awards ?? []) as SpecialAwardItem[]) { const awardRounds = allRounds.filter((r) => r.specialAwardId)
if (award.evaluationRoundId) { const groups = new Map<string, { award: SpecialAwardItem; rounds: RoundWithStats[] }>()
const existing = map.get(award.evaluationRoundId) ?? []
existing.push(award) for (const round of awardRounds) {
map.set(award.evaluationRoundId, existing) 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 return Array.from(groups.values())
}, [awards]) }, [compDetail?.rounds, awards])
const floatingAwards = useMemo(() => { const floatingAwards = useMemo(() => {
return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId) // Awards that have no evaluationRoundId AND no rounds linked via specialAwardId
}, [awards]) 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 = () => { const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType || !comp) { if (!roundForm.name.trim() || !roundForm.roundType || !comp) {
@@ -271,8 +285,10 @@ export default function RoundsPage() {
const activeFilter = filterType !== 'all' const activeFilter = filterType !== 'all'
const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0 const totalProjects = (compDetail as any)?.distinctProjectCount ?? 0
const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[] 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 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 ( return (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
@@ -313,7 +329,7 @@ export default function RoundsPage() {
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground"> <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 className="text-muted-foreground/30">|</span>
<span>{totalProjects} projects</span> <span>{totalProjects} projects</span>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
@@ -330,12 +346,12 @@ export default function RoundsPage() {
</span> </span>
</> </>
)} )}
{awards && awards.length > 0 && ( {awardTrackGroups.length > 0 && (
<> <>
<span className="text-muted-foreground/30">|</span> <span className="text-muted-foreground/30">|</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Award className="h-3.5 w-3.5" /> <Trophy className="h-3.5 w-3.5" />
{awards.length} awards {awardTrackGroups.length} award {awardTrackGroups.length === 1 ? 'track' : 'tracks'}
</span> </span>
</> </>
)} )}
@@ -389,7 +405,7 @@ export default function RoundsPage() {
</div> </div>
{/* ── Pipeline View ───────────────────────────────────────────── */} {/* ── Pipeline View ───────────────────────────────────────────── */}
{rounds.length === 0 ? ( {mainRounds.length === 0 && awardTrackGroups.length === 0 ? (
<div className="py-16 text-center border-2 border-dashed rounded-lg"> <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" /> <FileBox className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -397,142 +413,79 @@ export default function RoundsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-6">
{/* ── Main Competition Pipeline ───────────────────────── */}
{mainRounds.length > 0 && (
<div className="relative"> <div className="relative">
{/* Main pipeline track */} {mainRounds.map((round, index) => (
{rounds.map((round, index) => { <RoundRow
const isLast = index === rounds.length - 1 key={round.id}
const typeColors = ROUND_TYPE_COLORS[round.roundType] ?? ROUND_TYPE_COLORS.INTAKE round={round}
const statusStyle = ROUND_STATUS_STYLES[round.status] ?? ROUND_STATUS_STYLES.ROUND_DRAFT isLast={index === mainRounds.length - 1}
const projectCount = round._count.projectRoundStates />
const assignmentCount = round._count.assignments ))}
const roundAwards = awardsByRound.get(round.id) ?? [] </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 ( 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 <div
className="h-3.5 w-3.5 rounded-full border-2 border-white shadow-sm" key={award.id}
style={{ backgroundColor: statusStyle.color }} className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
/>
{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"
> >
<div {/* Award track header */}
className={cn( <Link href={`/admin/awards/${award.id}` as Route}>
'group flex items-center gap-3 px-3 py-2.5 rounded-md border-l-[3px] cursor-pointer transition-all', <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">
'bg-white hover:bg-gray-50/80 hover:shadow-sm', <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" />
style={{ borderLeftColor: typeColors.dot }} </div>
> <div className="min-w-0 flex-1">
{/* Round type indicator */} <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( <span className={cn(
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]', 'text-[10px] font-semibold uppercase tracking-wide px-1.5 py-px rounded',
typeColors.text isExclusive
? 'bg-red-100 text-red-600'
: 'bg-blue-100 text-blue-600'
)}> )}>
{round.roundType.replace('_', ' ')} {isExclusive ? 'Exclusive pool' : 'Parallel'}
</span> </span>
<span className="text-muted-foreground/30">&middot;</span>
{/* Round name */} <span className={statusColor}>
<span className="text-sm font-semibold text-[#053d57] truncate group-hover:text-[#de0f1e] transition-colors min-w-0 flex-1"> {award.status.replace('_', ' ')}
{round.name}
</span> </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> </div>
</div>
{/* Status badge (compact) */} <ArrowRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
<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> </div>
</Link> </Link>
{/* Awards branching off this round */} {/* Award track rounds */}
{roundAwards.length > 0 && ( <div className="px-4 py-2">
<div className="flex items-center gap-2 shrink-0"> {awardRounds.map((round, index) => (
{/* Connector dash */} <RoundRow
<div className="w-4 h-px bg-amber-300" /> key={round.id}
{/* Award nodes */} round={round}
<div className="flex flex-col gap-1"> isLast={index === awardRounds.length - 1}
{roundAwards.map((award) => ( />
<AwardNode key={award.id} award={award} />
))} ))}
</div> </div>
</div> </div>
)}
</div>
</div>
</div>
</div>
) )
})} })}
{/* Floating awards (no evaluationRoundId) */} {/* Floating awards (no linked rounds) */}
{floatingAwards.length > 0 && ( {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"> <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-2 pl-10">
Unlinked Awards Unlinked Awards
</p> </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 ────────────────────────────────────────────────────────────── // ─── Award Node ──────────────────────────────────────────────────────────────
function AwardNode({ award }: { award: SpecialAwardItem }) { function AwardNode({ award }: { award: SpecialAwardItem }) {