fix: separate main pipeline from award tracks on rounds page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m24s
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:
@@ -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="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) ?? []
|
||||
<div className="space-y-6">
|
||||
{/* ── Main Competition Pipeline ───────────────────────── */}
|
||||
{mainRounds.length > 0 && (
|
||||
<div className="relative">
|
||||
{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"
|
||||
>
|
||||
<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 */}
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-wider shrink-0 w-[70px]',
|
||||
typeColors.text
|
||||
)}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</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} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={award.id}
|
||||
className="rounded-lg border border-amber-200/80 bg-amber-50/30 overflow-hidden"
|
||||
>
|
||||
{/* 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">·</span>
|
||||
<span className={cn(
|
||||
'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'
|
||||
)}>
|
||||
{isExclusive ? 'Exclusive pool' : 'Parallel'}
|
||||
</span>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span className={statusColor}>
|
||||
{award.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-[#de0f1e]/60 transition-colors shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 }) {
|
||||
|
||||
Reference in New Issue
Block a user