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('_', ' ')} +

+
+
+
) }