From de73a6f0805f1236e6e409d6290c2e5e964bf379 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 10:19:50 +0100 Subject: [PATCH] Rounds page: flat pipeline view with awards branching visualization Redesign the rounds page from a card-per-competition layout to a flat pipeline/timeline view. Rounds display as compact rows connected by a vertical track with status dots (pulsing green for active). Special awards branch off to the right from their linked evaluation round with connector lines and tooltip details. Competition settings moved to a dialog behind a gear icon. Filter pills replace the dropdown selector. Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/rounds/page.tsx | 1050 ++++++++++++++----------- 1 file changed, 576 insertions(+), 474 deletions(-) diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index a56cbc4..9fcfdb8 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -1,17 +1,11 @@ 'use client' -import { useState } from 'react' +import { useState, useMemo } from 'react' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -32,26 +26,29 @@ import { DialogTitle, } from '@/components/ui/dialog' import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible' + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Plus, - Layers, Calendar, - ChevronDown, - ChevronRight, Settings, Users, FileBox, Save, Loader2, + Award, + Trophy, + ArrowRight, } from 'lucide-react' import { useEdition } from '@/contexts/edition-context' +// ─── Constants ─────────────────────────────────────────────────────────────── + const ROUND_TYPES = [ { value: 'INTAKE', label: 'Intake' }, { value: 'FILTERING', label: 'Filtering' }, @@ -62,30 +59,33 @@ const ROUND_TYPES = [ { value: 'DELIBERATION', label: 'Deliberation' }, ] as const -const roundTypeColors: Record = { - INTAKE: 'bg-gray-100 text-gray-700', - FILTERING: 'bg-amber-100 text-amber-700', - EVALUATION: 'bg-blue-100 text-blue-700', - SUBMISSION: 'bg-purple-100 text-purple-700', - MENTORING: 'bg-teal-100 text-teal-700', - LIVE_FINAL: 'bg-red-100 text-red-700', - DELIBERATION: 'bg-indigo-100 text-indigo-700', +const ROUND_TYPE_COLORS: Record = { + INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' }, + FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' }, + EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' }, + SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' }, + MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' }, + LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' }, + DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' }, } -const statusConfig = { - DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700', dotClass: 'bg-gray-500' }, - ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700', dotClass: 'bg-emerald-500' }, - CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700', dotClass: 'bg-blue-500' }, - ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground', dotClass: 'bg-muted-foreground' }, -} as const - -const roundStatusColors: Record = { - ROUND_DRAFT: 'bg-gray-100 text-gray-600', - ROUND_ACTIVE: 'bg-emerald-100 text-emerald-700', - ROUND_CLOSED: 'bg-blue-100 text-blue-700', - ROUND_ARCHIVED: 'bg-muted text-muted-foreground', +const ROUND_STATUS_STYLES: Record = { + ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' }, + ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true }, + ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' }, + ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' }, } +const AWARD_STATUS_COLORS: Record = { + DRAFT: 'text-gray-500', + NOMINATIONS_OPEN: 'text-amber-600', + VOTING_OPEN: 'text-emerald-600', + CLOSED: 'text-blue-600', + ARCHIVED: 'text-gray-400', +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + type RoundWithStats = { id: string name: string @@ -99,6 +99,18 @@ type RoundWithStats = { _count: { projectRoundStates: number; assignments: number } } +type SpecialAwardItem = { + id: string + name: string + status: string + evaluationRoundId: string | null + eligibilityMode: string + _count: { eligibilities: number; jurors: number; votes: number } + winnerProject: { id: string; title: string; teamName: string | null } | null +} + +// ─── Main Page ─────────────────────────────────────────────────────────────── + export default function RoundsPage() { const { currentEdition } = useEdition() const programId = currentEdition?.id @@ -106,9 +118,9 @@ export default function RoundsPage() { const [addRoundOpen, setAddRoundOpen] = useState(false) const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' }) - const [expandedCompetitions, setExpandedCompetitions] = useState>(new Set()) - const [editingCompetition, setEditingCompetition] = useState(null) + const [settingsOpen, setSettingsOpen] = useState(false) const [competitionEdits, setCompetitionEdits] = useState>({}) + const [editingCompId, setEditingCompId] = useState(null) const [filterType, setFilterType] = useState('all') const { data: competitions, isLoading } = trpc.competition.list.useQuery( @@ -116,9 +128,23 @@ export default function RoundsPage() { { enabled: !!programId } ) + // Use the first (and usually only) competition + const comp = competitions?.[0] + + const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery( + { id: comp?.id! }, + { enabled: !!comp?.id } + ) + + const { data: awards } = trpc.specialAward.list.useQuery( + { programId: programId! }, + { enabled: !!programId } + ) + const createRoundMutation = trpc.round.create.useMutation({ onSuccess: () => { utils.competition.list.invalidate() + utils.competition.getById.invalidate() toast.success('Round created') setAddRoundOpen(false) setRoundForm({ name: '', roundType: '', competitionId: '' }) @@ -129,32 +155,46 @@ export default function RoundsPage() { const updateCompMutation = trpc.competition.update.useMutation({ onSuccess: () => { utils.competition.list.invalidate() - toast.success('Competition settings saved') - setEditingCompetition(null) + utils.competition.getById.invalidate() + toast.success('Settings saved') + setEditingCompId(null) setCompetitionEdits({}) + setSettingsOpen(false) }, onError: (err) => toast.error(err.message), }) - const toggleExpanded = (id: string) => { - setExpandedCompetitions((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } + const rounds = useMemo(() => { + const all = (compDetail?.rounds ?? []) as RoundWithStats[] + return filterType === 'all' ? all : all.filter((r) => r.roundType === filterType) + }, [compDetail?.rounds, filterType]) + + // Group awards by their evaluationRoundId + const awardsByRound = useMemo(() => { + const map = new Map() + for (const award of (awards ?? []) as SpecialAwardItem[]) { + if (award.evaluationRoundId) { + const existing = map.get(award.evaluationRoundId) ?? [] + existing.push(award) + map.set(award.evaluationRoundId, existing) + } + } + return map + }, [awards]) + + const floatingAwards = useMemo(() => { + return ((awards ?? []) as SpecialAwardItem[]).filter((a) => !a.evaluationRoundId) + }, [awards]) const handleCreateRound = () => { - if (!roundForm.name.trim() || !roundForm.roundType || !roundForm.competitionId) { + if (!roundForm.name.trim() || !roundForm.roundType || !comp) { toast.error('All fields are required') return } const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - const comp = competitions?.find((c) => c.id === roundForm.competitionId) - const nextOrder = comp ? (comp as any).rounds?.length ?? comp._count.rounds : 0 + const nextOrder = rounds.length createRoundMutation.mutate({ - competitionId: roundForm.competitionId, + competitionId: comp.id, name: roundForm.name.trim(), slug, roundType: roundForm.roundType as any, @@ -162,477 +202,539 @@ export default function RoundsPage() { }) } - const startEditCompetition = (comp: any) => { - setEditingCompetition(comp.id) + const startEditSettings = () => { + if (!comp) return + setEditingCompId(comp.id) setCompetitionEdits({ name: comp.name, - categoryMode: comp.categoryMode, - startupFinalistCount: comp.startupFinalistCount, - conceptFinalistCount: comp.conceptFinalistCount, - notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach, + categoryMode: (comp as any).categoryMode, + startupFinalistCount: (comp as any).startupFinalistCount, + conceptFinalistCount: (comp as any).conceptFinalistCount, + notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach, }) + setSettingsOpen(true) } - const saveCompetitionEdit = (id: string) => { - updateCompMutation.mutate({ id, ...competitionEdits } as any) + const saveSettings = () => { + if (!editingCompId) return + updateCompMutation.mutate({ id: editingCompId, ...competitionEdits } as any) } + // ─── No edition ────────────────────────────────────────────────────────── + if (!programId) { return (

Rounds

- - - -

No Edition Selected

-

Select an edition from the sidebar

-
-
+
+ +

No Edition Selected

+

Select an edition from the sidebar

+
) } - return ( -
- {/* Header */} -
-
-

Competition Rounds

-

- Configure and manage evaluation rounds for {currentEdition?.name} -

-
- -
+ // ─── Loading ───────────────────────────────────────────────────────────── - {/* Filter */} - -
- - + if (isLoading || isLoadingDetail) { + return ( +
+
+ +
- - - {/* Loading */} - {isLoading && ( -
- {[1, 2].map((i) => ( - - - - - - - - - + +
+ {[1, 2, 3, 4, 5].map((i) => ( + ))}
- )} +
+ ) + } - {/* Empty State */} - {!isLoading && (!competitions || competitions.length === 0) && ( - - -
- + // ─── No competition ────────────────────────────────────────────────────── + + if (!comp) { + return ( +
+

Competition Pipeline

+
+
+ +
+

No Competition Configured

+

+ Create a competition to start building the evaluation pipeline. +

+ + + +
+
+ ) + } + + // ─── Main Render ───────────────────────────────────────────────────────── + + const activeFilter = filterType !== 'all' + const totalProjects = rounds.reduce((s, r) => s + r._count.projectRoundStates, 0) + const totalAssignments = rounds.reduce((s, r) => s + r._count.assignments, 0) + const activeRound = rounds.find((r) => r.status === 'ROUND_ACTIVE') + + return ( + +
+ {/* ── Header Bar ──────────────────────────────────────────────── */} +
+
+
+

+ {comp.name} +

+ + + + + Competition settings +
-

No Competitions Yet

-

- Create a competition first, then add rounds to define the evaluation flow. -

- - - - - - )} - - {/* Competition Groups with Rounds */} - {competitions && competitions.length > 0 && ( -
- {competitions.map((comp) => { - const status = comp.status as keyof typeof statusConfig - const cfg = statusConfig[status] || statusConfig.DRAFT - const isExpanded = expandedCompetitions.has(comp.id) || competitions.length === 1 - const isEditing = editingCompetition === comp.id +
+ {rounds.length} rounds + | + {totalProjects} projects + | + {totalAssignments} assignments + {activeRound && ( + <> + | + + + + + + {activeRound.name} + + + )} + {awards && awards.length > 0 && ( + <> + | + + + {awards.length} awards + + + )} +
+
+ +
+ {/* ── Filter Pills ────────────────────────────────────────────── */} +
+ + {ROUND_TYPES.map((rt) => { + const colors = ROUND_TYPE_COLORS[rt.value] + const isActive = filterType === rt.value return ( - toggleExpanded(comp.id)} - onStartEdit={() => startEditCompetition(comp)} - onCancelEdit={() => { setEditingCompetition(null); setCompetitionEdits({}) }} - onSaveEdit={() => saveCompetitionEdit(comp.id)} - onEditChange={setCompetitionEdits} - /> + ) })}
- )} - {/* Add Round Dialog */} - - - - Add Round - - Create a new round in a competition. - - -
-
- - -
-
- - setRoundForm({ ...roundForm, name: e.target.value })} - /> -
-
- - -
+ {/* ── Pipeline View ───────────────────────────────────────────── */} + {rounds.length === 0 ? ( +
+ +

+ {activeFilter ? 'No rounds match this filter.' : 'No rounds yet. Add one to start building the pipeline.'} +

- - - - - -
-
- ) -} + ) : ( +
+ {/* 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) ?? [] -// ─── Competition Group Component ───────────────────────────────────────────── + return ( +
+ {/* Round row with pipeline connector */} +
+ {/* Left: pipeline track */} +
+ {/* Status dot */} + + +
+
+ {statusStyle.pulse && ( +
+ )} +
+ + + {statusStyle.label} + + + {/* Connector line */} + {!isLast && ( +
+ )} +
-type CompetitionGroupProps = { - competition: any - statusConfig: { label: string; bgClass: string; dotClass: string } - isExpanded: boolean - isEditing: boolean - competitionEdits: Record - filterType: string - updateCompMutation: any - onToggle: () => void - onStartEdit: () => void - onCancelEdit: () => void - onSaveEdit: () => void - onEditChange: (edits: Record) => void -} + {/* Right: round content + awards */} +
+
+ {/* Round row */} + +
+ {/* Round type indicator */} + + {round.roundType.replace('_', ' ')} + -function CompetitionGroup({ - competition: comp, - statusConfig: cfg, - isExpanded, - isEditing, - competitionEdits, - filterType, - updateCompMutation, - onToggle, - onStartEdit, - onCancelEdit, - onSaveEdit, - onEditChange, -}: CompetitionGroupProps) { - // We need to fetch rounds for this competition - const { data: compDetail } = trpc.competition.getById.useQuery( - { id: comp.id }, - { enabled: isExpanded } - ) + {/* Round name */} + + {round.name} + - const rounds = (compDetail?.rounds ?? []) as RoundWithStats[] - const filteredRounds = filterType === 'all' - ? rounds - : rounds.filter((r: RoundWithStats) => r.roundType === filterType) + {/* Stats cluster */} +
+ {round.juryGroup && ( + + + {round.juryGroup.name} + + )} + + + {projectCount} + + {assignmentCount > 0 && ( + {assignmentCount} eval + )} + {(round.windowOpenAt || round.windowCloseAt) && ( + + + {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' }) + : ''} + + )} +
- return ( - - {/* Competition Header */} - -
-
+ + + {/* Awards branching off this round */} + {roundAwards.length > 0 && ( +
+ {/* Connector dash */} +
+ {/* Award nodes */} +
+ {roundAwards.map((award) => ( + + ))} +
+
+ )} +
+
+
+
+ ) + })} + + {/* Floating awards (no evaluationRoundId) */} + {floatingAwards.length > 0 && ( +
+

+ Unlinked Awards +

+
+ {floatingAwards.map((award) => ( + + ))} +
+
)} - - -
-
- - {comp.name} - - - {cfg.label} - -
-
-
- - {comp._count.rounds} rounds -
- · -
- - {comp._count.juryGroups} juries -
-
+ )} - -
- - - {/* Inline Competition Settings Editor */} - {isEditing && ( - -
-
-

Competition Settings

-
-
- - onEditChange({ ...competitionEdits, name: e.target.value })} - className="h-9" - /> -
-
- - onEditChange({ ...competitionEdits, categoryMode: e.target.value })} - className="h-9" - /> -
-
- - onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })} - /> -
-
- - onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })} - /> -
-
- onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })} - /> - -
+ {/* ── Settings Panel (Collapsible) ─────────────────────────── */} + + + + Competition Settings + + Configure competition parameters for {comp.name}. + + +
+
+ + setCompetitionEdits({ ...competitionEdits, name: e.target.value })} + className="h-9" + /> +
+
+ + setCompetitionEdits({ ...competitionEdits, categoryMode: e.target.value })} + className="h-9" + /> +
+
+ + setCompetitionEdits({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })} + /> +
+
+ + setCompetitionEdits({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })} + /> +
+
+ setCompetitionEdits({ ...competitionEdits, notifyOnDeadlineApproach: v })} + /> +
-
+ + - -
-
- - )} + + + - {/* Rounds List */} - {isExpanded && ( - - {!compDetail ? ( -
- - + {/* ── Add Round Dialog ─────────────────────────────────────── */} + + + + Add Round + + Add a new round to the pipeline. + + +
+
+ + setRoundForm({ ...roundForm, name: e.target.value })} + /> +
+
+ + +
- ) : filteredRounds.length === 0 ? ( -
- -

- {filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'} -

-
- ) : ( -
- {filteredRounds.map((round: RoundWithStats, index: number) => { - const isLast = index === filteredRounds.length - 1 - const projectCount = round._count.projectRoundStates - const progressPercent = projectCount > 0 ? Math.min((round._count.assignments / (projectCount * 3)) * 100, 100) : 0 - - return ( -
- -
- {/* Round Number Circle */} -
-
- {round.sortOrder + 1} -
- {!isLast && ( -
- )} -
- - {/* Round Content */} -
-
-
-

- {round.name} -

-
-
- - {projectCount} - projects -
- · -
- - {round._count.assignments} - assignments -
- {round.juryGroup && ( - <> - · - Jury: {round.juryGroup.name} - - )} - {(round.windowOpenAt || round.windowCloseAt) && ( - <> - · -
- - - {round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()} - {round.windowOpenAt && round.windowCloseAt && ' - '} - {round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()} - -
- - )} -
-
-
- - {round.roundType.replace('_', ' ')} - - -
-
- - {/* Progress Bar */} - {projectCount > 0 && ( -
-
-
-
-
- )} -
-
- -
- ) - })} -
- )} - - )} - + + + + + +
+
+ + ) +} + +// ─── Award Node ────────────────────────────────────────────────────────────── + +function AwardNode({ award }: { award: SpecialAwardItem }) { + const statusColor = AWARD_STATUS_COLORS[award.status] ?? 'text-gray-500' + const isExclusive = award.eligibilityMode === 'SEPARATE_POOL' + const eligible = award._count.eligibilities + const hasWinner = !!award.winnerProject + + return ( + + + + +
+ + + {award.name} + + {eligible > 0 && ( + {eligible} + )} + + {isExclusive ? 'Excl' : 'Par'} + +
+ +
+ +

{award.name}

+

+ {isExclusive ? 'Exclusive pool (projects leave main track)' : 'Parallel (projects stay in main track)'} +

+

+ {eligible} eligible · {award._count.jurors} jurors · {award._count.votes} votes +

+ {hasWinner && ( +

+ Winner: {award.winnerProject!.title} +

+ )} +

+ Status: {award.status.replace('_', ' ')} +

+
+
+
) }