'use client' 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 { Skeleton } from '@/components/ui/skeleton' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Plus, Calendar, 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' }, { value: 'EVALUATION', label: 'Evaluation' }, { value: 'SUBMISSION', label: 'Submission' }, { value: 'MENTORING', label: 'Mentoring' }, { value: 'LIVE_FINAL', label: 'Live Final' }, { value: 'DELIBERATION', label: 'Deliberation' }, ] as const 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 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 slug: string roundType: string status: string sortOrder: number windowOpenAt: string | null windowCloseAt: string | null specialAwardId: string | null juryGroup: { id: string; name: string } | null _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 const utils = trpc.useUtils() const [addRoundOpen, setAddRoundOpen] = useState(false) const [roundForm, setRoundForm] = useState({ name: '', roundType: '', competitionId: '' }) const [settingsOpen, setSettingsOpen] = useState(false) const [competitionEdits, setCompetitionEdits] = useState>({}) const [editingCompId, setEditingCompId] = useState(null) const [filterType, setFilterType] = useState('all') const [selectedCompId, setSelectedCompId] = useState(null) const { data: competitions, isLoading } = trpc.competition.list.useQuery( { programId: programId! }, { enabled: !!programId, refetchInterval: 30_000 } ) // Auto-select first competition, or use the user's selection const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0] const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery( { id: comp?.id! }, { enabled: !!comp?.id, refetchInterval: 30_000 } ) 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: '' }) }, onError: (err) => toast.error(err.message), }) const updateCompMutation = trpc.competition.update.useMutation({ onSuccess: () => { utils.competition.list.invalidate() utils.competition.getById.invalidate() toast.success('Settings saved') setEditingCompId(null) setCompetitionEdits({}) setSettingsOpen(false) }, onError: (err) => toast.error(err.message), }) 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 || !comp) { toast.error('All fields are required') return } const slug = roundForm.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const nextOrder = (compDetail?.rounds ?? []).length createRoundMutation.mutate({ competitionId: comp.id, name: roundForm.name.trim(), slug, roundType: roundForm.roundType as any, sortOrder: nextOrder, }) } const startEditSettings = () => { if (!comp) return setEditingCompId(comp.id) setCompetitionEdits({ name: comp.name, categoryMode: (comp as any).categoryMode, startupFinalistCount: (comp as any).startupFinalistCount, conceptFinalistCount: (comp as any).conceptFinalistCount, notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach, }) setSettingsOpen(true) } 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

) } // ─── Loading ───────────────────────────────────────────────────────────── if (isLoading || isLoadingDetail) { return (
{[1, 2, 3, 4, 5].map((i) => ( ))}
) } // ─── 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 = (compDetail as any)?.distinctProjectCount ?? 0 const allRounds = (compDetail?.rounds ?? []) as RoundWithStats[] const totalAssignments = allRounds.reduce((s, r) => s + r._count.assignments, 0) const activeRound = allRounds.find((r) => r.status === 'ROUND_ACTIVE') return (
{/* Competition selector (when multiple exist) */} {competitions && competitions.length > 1 && ( )} {/* ── Header Bar ──────────────────────────────────────────────── */}

{comp.name}

Competition settings
{allRounds.filter((r) => !r.specialAwardId).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 ( ) })}
{/* ── 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) ?? [] return (
{/* Round row with pipeline connector */}
{/* Left: pipeline track */}
{/* Status dot */}
{statusStyle.pulse && (
)}
{statusStyle.label} {/* Connector line */} {!isLast && (
)}
{/* Right: round content + awards */}
{/* Round row */}
{/* Round type indicator */} {round.roundType.replace('_', ' ')} {/* Round name */} {round.name} {/* Stats cluster */}
{round.juryGroup && ( {round.juryGroup.name} )} {projectCount} {assignmentCount > 0 && ( {assignmentCount} asgn )} {(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' }) : ''} )}
{/* Status badge (compact) */} {statusStyle.label} {/* Arrow */}
{/* 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) => ( ))}
)}
)} {/* ── 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 })} />
{/* ── Add Round Dialog ─────────────────────────────────────── */} Add Round Add a new round to the pipeline.
setRoundForm({ ...roundForm, name: e.target.value })} />
) } // ─── 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('_', ' ')}

) }