'use client' import { useState, useMemo, useCallback } from 'react' import { useParams } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { ArrowLeft, Save, Loader2, ChevronDown, Play, Square, Archive, Layers, Users, CalendarDays, BarChart3, ClipboardList, Settings, Zap, Shield, UserPlus, CheckCircle2, AlertTriangle, FileText, Trophy, Clock, Send, Download, Plus, Trash2, ArrowRight, RotateCcw, X, Check, ChevronsUpDown, Search, } from 'lucide-react' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { ScrollArea } from '@/components/ui/scroll-area' import { RoundConfigForm } from '@/components/admin/competition/round-config-form' import { ProjectStatesTable } from '@/components/admin/round/project-states-table' // SubmissionWindowManager removed — round dates + file requirements in Config are sufficient import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { AnimatedCard } from '@/components/shared/animated-container' import { DateTimePicker } from '@/components/ui/datetime-picker' import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog' import { motion } from 'motion/react' // ── Status & type config maps ────────────────────────────────────────────── const roundStatusConfig = { ROUND_DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700', dotClass: 'bg-gray-500', description: 'Not yet active. Configure before launching.', }, ROUND_ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700', dotClass: 'bg-emerald-500 animate-pulse', description: 'Round is live. Projects can be processed.', }, ROUND_CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700', dotClass: 'bg-blue-500', description: 'No longer accepting changes. Results are final.', }, ROUND_ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground', dotClass: 'bg-muted-foreground', description: 'Historical record only.', }, } as const const roundTypeConfig: Record = { INTAKE: { label: 'Intake', color: 'bg-gray-100 text-gray-700', description: 'Collecting applications' }, FILTERING: { label: 'Filtering', color: 'bg-amber-100 text-amber-700', description: 'AI + manual screening' }, EVALUATION: { label: 'Evaluation', color: 'bg-blue-100 text-blue-700', description: 'Jury evaluation & scoring' }, SUBMISSION: { label: 'Submission', color: 'bg-purple-100 text-purple-700', description: 'Document submission' }, MENTORING: { label: 'Mentoring', color: 'bg-teal-100 text-teal-700', description: 'Mentor-guided development' }, LIVE_FINAL: { label: 'Live Final', color: 'bg-red-100 text-red-700', description: 'Live presentations & voting' }, DELIBERATION: { label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700', description: 'Final jury deliberation' }, } const stateColors: Record = { PENDING: 'bg-gray-400', IN_PROGRESS: 'bg-blue-500', PASSED: 'bg-green-500', REJECTED: 'bg-red-500', COMPLETED: 'bg-emerald-500', WITHDRAWN: 'bg-orange-400', } // ═══════════════════════════════════════════════════════════════════════════ // Main Page Component // ═══════════════════════════════════════════════════════════════════════════ export default function RoundDetailPage() { const params = useParams() const roundId = params.roundId as string const [config, setConfig] = useState>({}) const [hasChanges, setHasChanges] = useState(false) const [activeTab, setActiveTab] = useState('overview') const [previewSheetOpen, setPreviewSheetOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false) const [aiRecommendations, setAiRecommendations] = useState<{ STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }> BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }> } | null>(null) const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false) const [createJuryOpen, setCreateJuryOpen] = useState(false) const [newJuryName, setNewJuryName] = useState('') const [addMemberOpen, setAddMemberOpen] = useState(false) const [closeAndAdvance, setCloseAndAdvance] = useState(false) const utils = trpc.useUtils() // ── Core data queries ────────────────────────────────────────────────── const { data: round, isLoading } = trpc.round.getById.useQuery( { id: roundId }, { refetchInterval: 15_000, refetchOnWindowFocus: true }, ) const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery( { roundId }, { refetchInterval: 10_000, refetchOnWindowFocus: true }, ) const competitionId = round?.competitionId ?? '' const { data: juryGroups } = trpc.juryGroup.list.useQuery( { competitionId }, { enabled: !!competitionId, refetchInterval: 30_000, refetchOnWindowFocus: true }, ) const { data: fileRequirements } = trpc.file.listRequirements.useQuery( { roundId }, { refetchInterval: 15_000, refetchOnWindowFocus: true }, ) // Fetch awards linked to this round const { data: competition } = trpc.competition.getById.useQuery( { id: competitionId }, { enabled: !!competitionId, refetchInterval: 60_000 }, ) const programId = competition?.programId const { data: awards } = trpc.specialAward.list.useQuery( { programId: programId! }, { enabled: !!programId, refetchInterval: 60_000 }, ) const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? [] // Sync config from server when not dirty if (round && !hasChanges) { const roundConfig = (round.configJson as Record) ?? {} if (JSON.stringify(roundConfig) !== JSON.stringify(config)) { setConfig(roundConfig) } } // ── Mutations ────────────────────────────────────────────────────────── const updateMutation = trpc.round.update.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) toast.success('Round configuration saved') setHasChanges(false) }, onError: (err) => toast.error(err.message), }) const activateMutation = trpc.roundEngine.activate.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) toast.success('Round activated') }, onError: (err) => toast.error(err.message), }) const closeMutation = trpc.roundEngine.close.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) toast.success('Round closed') if (closeAndAdvance) { setCloseAndAdvance(false) // Small delay to let cache invalidation complete before opening dialog setTimeout(() => setAdvanceDialogOpen(true), 300) } }, onError: (err) => { setCloseAndAdvance(false) toast.error(err.message) }, }) const reopenMutation = trpc.roundEngine.reopen.useMutation({ onSuccess: (data) => { utils.round.getById.invalidate({ id: roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) const msg = data.pausedRounds?.length ? `Round reopened. Paused: ${data.pausedRounds.join(', ')}` : 'Round reopened' toast.success(msg) }, onError: (err) => toast.error(err.message), }) const archiveMutation = trpc.roundEngine.archive.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) toast.success('Round archived') }, onError: (err) => toast.error(err.message), }) const assignJuryMutation = trpc.round.update.useMutation({ onSuccess: () => { utils.round.getById.invalidate({ id: roundId }) utils.juryGroup.list.invalidate({ competitionId }) toast.success('Jury group updated') }, onError: (err) => toast.error(err.message), }) // Jury group detail query (for the assigned group) const juryGroupId = round?.juryGroupId ?? '' const { data: juryGroupDetail } = trpc.juryGroup.getById.useQuery( { id: juryGroupId }, { enabled: !!juryGroupId, refetchInterval: 10_000 }, ) const createJuryMutation = trpc.juryGroup.create.useMutation({ onSuccess: (newGroup) => { utils.juryGroup.list.invalidate({ competitionId }) // Auto-assign the new jury group to this round assignJuryMutation.mutate({ id: roundId, juryGroupId: newGroup.id }) toast.success(`Jury "${newGroup.name}" created and assigned`) setCreateJuryOpen(false) setNewJuryName('') }, onError: (err) => toast.error(err.message), }) const deleteJuryMutation = trpc.juryGroup.delete.useMutation({ onSuccess: (result) => { utils.juryGroup.list.invalidate({ competitionId }) utils.round.getById.invalidate({ id: roundId }) toast.success(`Jury "${result.name}" deleted`) }, onError: (err) => toast.error(err.message), }) const removeJuryMemberMutation = trpc.juryGroup.removeMember.useMutation({ onSuccess: () => { if (juryGroupId) utils.juryGroup.getById.invalidate({ id: juryGroupId }) toast.success('Member removed') }, onError: (err) => toast.error(err.message), }) const advanceMutation = trpc.round.advanceProjects.useMutation({ onSuccess: (data) => { utils.round.getById.invalidate({ id: roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`) setAdvanceDialogOpen(false) }, onError: (err) => toast.error(err.message), }) const shortlistMutation = trpc.round.generateAIRecommendations.useMutation({ onSuccess: (data) => { if (data.success) { setAiRecommendations(data.recommendations) toast.success( `AI recommendations generated: ${data.recommendations.STARTUP.length} startups, ${data.recommendations.BUSINESS_CONCEPT.length} concepts` + (data.tokensUsed ? ` (${data.tokensUsed} tokens)` : ''), ) } else { toast.error(data.errors?.join('; ') || 'AI shortlist failed') } setShortlistDialogOpen(false) }, onError: (err) => { toast.error(err.message) setShortlistDialogOpen(false) }, }) const isTransitioning = activateMutation.isPending || closeMutation.isPending || reopenMutation.isPending || archiveMutation.isPending const handleConfigChange = useCallback((newConfig: Record) => { setConfig(newConfig) setHasChanges(true) }, []) const handleSave = useCallback(() => { updateMutation.mutate({ id: roundId, configJson: config }) }, [roundId, config, updateMutation]) // ── Computed values ──────────────────────────────────────────────────── const projectCount = round?._count?.projectRoundStates ?? 0 const stateCounts = useMemo(() => projectStates?.reduce((acc: Record, ps: any) => { acc[ps.state] = (acc[ps.state] || 0) + 1 return acc }, {} as Record) ?? {}, [projectStates]) const passedCount = stateCounts['PASSED'] ?? 0 const juryGroup = round?.juryGroup const juryMemberCount = juryGroup?.members?.length ?? 0 const isFiltering = round?.roundType === 'FILTERING' const isEvaluation = round?.roundType === 'EVALUATION' const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '') const hasAwards = hasJury const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route // ── Loading state ────────────────────────────────────────────────────── if (isLoading) { return (
{/* Header skeleton — dark gradient placeholder */}
{[1, 2, 3, 4].map((i) => )}
) } if (!round) { return (

Round Not Found

This round does not exist.

) } const status = round.status as keyof typeof roundStatusConfig const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE // ── Readiness checklist ──────────────────────────────────────────────── const readinessItems = [ { label: 'Projects assigned', ready: projectCount > 0, detail: projectCount > 0 ? `${projectCount} projects` : 'No projects yet', action: projectCount === 0 ? poolLink : undefined, actionLabel: 'Assign Projects', }, ...(hasJury ? [{ label: 'Jury group set', ready: !!juryGroup, detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned', action: undefined as Route | undefined, actionLabel: undefined as string | undefined, }] : []), { label: 'Dates configured', ready: !!round.windowOpenAt && !!round.windowCloseAt, detail: round.windowOpenAt && round.windowCloseAt ? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}` : 'No dates set \u2014 configure in Config tab', action: undefined as Route | undefined, actionLabel: undefined as string | undefined, }, { label: 'File requirements set', ready: (fileRequirements?.length ?? 0) > 0, detail: (fileRequirements?.length ?? 0) > 0 ? `${fileRequirements?.length} requirement(s)` : 'No file requirements \u2014 configure in Config tab', action: undefined as Route | undefined, actionLabel: undefined as string | undefined, }, ] const readyCount = readinessItems.filter((i) => i.ready).length // ═════════════════════════════════════════════════════════════════════════ // Render // ═════════════════════════════════════════════════════════════════════════ return (
{/* ===== HEADER — Dark Blue gradient banner ===== */}

{round.name}

{typeCfg.label} {/* Status dropdown */} {status === 'ROUND_DRAFT' && ( activateMutation.mutate({ roundId })} disabled={isTransitioning} > Activate Round )} {status === 'ROUND_ACTIVE' && ( closeMutation.mutate({ roundId })} disabled={isTransitioning} > Close Round )} {status === 'ROUND_CLOSED' && ( <> reopenMutation.mutate({ roundId })} disabled={isTransitioning} > Reopen Round archiveMutation.mutate({ roundId })} disabled={isTransitioning} > Archive Round )} {isTransitioning && (
Updating...
)}

{typeCfg.description}

{/* Action buttons */}
{hasChanges && ( )}
{/* ===== STATS BAR — Accent-bordered cards ===== */}
{/* Projects */}
Projects

{projectCount}

{Object.entries(stateCounts).map(([state, count]) => ( {String(count)} {state.toLowerCase().replace('_', ' ')} ))}
{/* Jury (with inline group selector) */}
Jury
{juryGroups && juryGroups.length > 0 ? ( ) : juryGroup ? ( <>

{juryMemberCount}

{juryGroup.name}

) : ( <>

No jury groups yet

)}
{/* Window */}
Window
{round.windowOpenAt || round.windowCloseAt ? ( <>

{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleDateString() : 'No start'}

{round.windowCloseAt ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}` : 'No deadline'}

) : ( <>

No dates set

)}
{/* Advancement */}
Advancement
{round.advancementRules && round.advancementRules.length > 0 ? ( <>

{round.advancementRules.length}

{round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}

) : ( <>

Admin selection

)}
{/* ===== TABS — Underline style ===== */}
{[ { value: 'overview', label: 'Overview', icon: Zap }, { value: 'projects', label: 'Projects', icon: Layers }, ...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []), ...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []), { value: 'config', label: 'Config', icon: Settings }, ...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []), ].map((tab) => ( {tab.label} {tab.value === 'awards' && roundAwards.length > 0 && ( {roundAwards.length} )} ))}
{/* ═══════════ OVERVIEW TAB ═══════════ */} {/* Readiness Checklist with Progress Ring */}
{/* SVG Progress Ring */}
{readyCount}/{readinessItems.length}
Launch Readiness {readyCount === readinessItems.length ? 'All checks passed — ready to go' : `${readinessItems.length - readyCount} item(s) remaining`}
{readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
{readinessItems.map((item) => (
{item.ready ? ( ) : ( )}

{item.label}

{item.detail}

{item.action && ( )}
))}
{/* Quick Actions — Grouped & styled */} Quick Actions Common operations for this round {/* Round Control Group */} {(status === 'ROUND_DRAFT' || status === 'ROUND_ACTIVE' || status === 'ROUND_CLOSED') && (

Round Control

{status === 'ROUND_DRAFT' && ( Activate this round? The round will go live. Projects can be processed and jury members will be able to see their assignments. Cancel activateMutation.mutate({ roundId })}> Activate )} {status === 'ROUND_ACTIVE' && ( Close this round? No further changes will be accepted. You can reactivate later if needed. {projectCount > 0 && ( {projectCount} projects are currently in this round. )} Cancel closeMutation.mutate({ roundId })}> Close Round )} {status === 'ROUND_CLOSED' && ( Reopen this round? The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically. Cancel reopenMutation.mutate({ roundId })}> Reopen )}
)} {/* Project Management Group */}

Project Management

{/* Advance projects (always visible when projects exist) */} {projectCount > 0 && ( )} {/* Close & Advance (active rounds with passed projects) */} {status === 'ROUND_ACTIVE' && passedCount > 0 && ( )} {/* Jury assignment for rounds that use jury */} {hasJury && !juryGroup && ( )} {/* Evaluation: manage assignments */} {isEvaluation && ( )}
{/* AI Tools Group */} {((isFiltering || isEvaluation) && projectCount > 0) && (

AI Tools

{isFiltering && ( )}
)}
{/* Advance Projects Dialog */} ({ id: r.id, name: r.name, sortOrder: r.sortOrder, roundType: r.roundType, }))} currentSortOrder={round?.sortOrder} /> {/* AI Shortlist Confirmation Dialog */} Generate AI Recommendations? The AI will analyze all project evaluations and generate a ranked shortlist for each category independently. {config.startupAdvanceCount ? ( Startup target: top {String(config.startupAdvanceCount)} ) : null} {config.conceptAdvanceCount ? ( Business Concept target: top {String(config.conceptAdvanceCount)} ) : null} {config.aiParseFiles ? ( Document parsing is enabled — the AI will read uploaded file contents. ) : null} Cancel shortlistMutation.mutate({ roundId })} disabled={shortlistMutation.isPending} > {shortlistMutation.isPending && } Generate {/* AI Recommendations Display */} {aiRecommendations && ( setAiRecommendations(null)} /> )} {/* Round Info + Project Breakdown */}
Round Details {[ { label: 'Type', value: {typeCfg.label} }, { label: 'Status', value: {statusCfg.label} }, { label: 'Sort Order', value: {round.sortOrder} }, ...(round.purposeKey ? [{ label: 'Purpose', value: {round.purposeKey} }] : []), { label: 'Jury Group', value: {juryGroup ? juryGroup.name : '\u2014'} }, { label: 'Opens', value: {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'} }, { label: 'Closes', value: {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'} }, ].map((row, i) => (
0 && 'border-t border-dotted border-muted')}> {row.label} {row.value}
))}
Project Breakdown {projectCount > 0 && ( {projectCount} total )}
{projectCount === 0 ? (

No projects assigned yet

) : (
{['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => { const count = stateCounts[state] || 0 if (count === 0) return null const pct = ((count / projectCount) * 100).toFixed(0) return (
{state.toLowerCase().replace('_', ' ')} {count} ({pct}%)
) })}
)}
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ FILTERING TAB ═══════════ */} {isFiltering && ( )} {/* ═══════════ JURY TAB ═══════════ */} {hasJury && ( {/* Jury Group Selector + Create */}
Jury Group Select or create a jury group for this round
{juryGroups && juryGroups.length > 0 ? (
{/* Delete button for currently selected jury group */} {round.juryGroupId && ( Delete jury group? This will permanently delete "{juryGroup?.name}" and remove all its members. Rounds using this jury group will be unlinked. This action cannot be undone. Cancel deleteJuryMutation.mutate({ id: round.juryGroupId! })} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={deleteJuryMutation.isPending} > {deleteJuryMutation.isPending && } Delete Jury )}
) : (

No Jury Groups

Create a jury group to assign members who will evaluate projects in this round.

)}
{/* Members list (only if a jury group is assigned) */} {juryGroupDetail && (
Members — {juryGroupDetail.name} {juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
{juryGroupDetail.members.length === 0 ? (

No Members Yet

Add jury members to start assigning projects for evaluation.

) : (
{juryGroupDetail.members.map((member: any, idx: number) => (

{member.user.name || 'Unnamed User'}

{member.user.email}

Remove member? Remove {member.user.name || member.user.email} from {juryGroupDetail.name}? Cancel removeJuryMemberMutation.mutate({ id: member.id })} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Remove
))}
)}
)} {/* Create Jury Dialog */} Create Jury Group Create a new jury group for this competition. It will be automatically assigned to this round.
setNewJuryName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && newJuryName.trim()) { createJuryMutation.mutate({ competitionId, name: newJuryName.trim(), slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''), }) } }} />
{/* Add Member Dialog */} {juryGroupId && ( { setAddMemberOpen(open) if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId }) }} /> )}
)} {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */} {isEvaluation && ( {/* Coverage Report */} {/* Generate Assignments */}
Assignment Generation AI-suggested jury-to-project assignments based on expertise and workload
{!juryGroup && (
Assign a jury group first before generating assignments.
)} {projectCount === 0 && (
Add projects to this round first.
)} {juryGroup && projectCount > 0 && (

Click "Generate Assignments" to preview AI-suggested assignments. You can review and execute them from the preview sheet.

)}
{/* Jury Progress + Score Distribution */}
{/* Actions: Send Reminders + Export */}
{/* Individual Assignments Table */} {/* Unassigned Queue */} {/* Assignment Preview Sheet */} {/* CSV Export Dialog */}
)} {/* ═══════════ CONFIG TAB ═══════════ */} {/* Round Dates */} Round Dates When this round starts and ends. Defines the active period for document uploads and evaluations.
updateMutation.mutate({ id: roundId, windowOpenAt: date })} placeholder="Select start date & time" clearable />
updateMutation.mutate({ id: roundId, windowCloseAt: date })} placeholder="Select end date & time" clearable />
{/* General Round Settings */} General Settings Settings that apply to this round regardless of type

Send an automated email to project applicants when their project enters this round

{ handleConfigChange({ ...config, notifyOnEntry: checked }) }} />

Send an email to project applicants when their project advances from this round to the next

{ handleConfigChange({ ...config, notifyOnAdvance: checked }) }} />

Allow AI to read the contents of uploaded files (PDF/text) for deeper analysis during filtering and evaluation

{ handleConfigChange({ ...config, aiParseFiles: checked }) }} />

Target number of projects per category to advance from this round

{ const val = e.target.value ? parseInt(e.target.value, 10) : undefined handleConfigChange({ ...config, startupAdvanceCount: val }) }} />
{ const val = e.target.value ? parseInt(e.target.value, 10) : undefined handleConfigChange({ ...config, conceptAdvanceCount: val }) }} />
{/* Round-type-specific config */} ({ id: jg.id, name: jg.name }))} /> {/* Evaluation Criteria Editor (EVALUATION rounds only) */} {isEvaluation && } {/* Document Requirements */} Document Requirements Files applicants must submit for this round {round.windowCloseAt && ( <> — due by {new Date(round.windowCloseAt).toLocaleDateString()} )}
{/* ═══════════ AWARDS TAB ═══════════ */} {hasAwards && ( {roundAwards.length === 0 ? (

No Awards Linked

Create an award and set this round as its evaluation round to see it here

) : (
{roundAwards.map((award) => { const eligibleCount = award._count?.eligibilities || 0 const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null const ruleCount = autoTagRules?.rules?.length || 0 return (

{award.name}

{award.eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Stay in Main'}
{award.description && (

{award.description}

)}
{ruleCount}
{ruleCount === 1 ? 'rule' : 'rules'}
{eligibleCount}
eligible
) })}
)}
)}
) } // ═══════════════════════════════════════════════════════════════════════════ // Sub-components // ═══════════════════════════════════════════════════════════════════════════ // ── Unassigned projects queue ──────────────────────────────────────────── function RoundUnassignedQueue({ roundId }: { roundId: string }) { const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery( { roundId, requiredReviews: 3 }, { refetchInterval: 15_000 }, ) return ( Unassigned Projects Projects with fewer than 3 jury assignments {isLoading ? (
{[1, 2, 3].map((i) => )}
) : unassigned && unassigned.length > 0 ? (
{unassigned.map((project: any) => (

{project.title}

{project.competitionCategory || 'No category'} {project.teamName && ` \u00b7 ${project.teamName}`}

{project.assignmentCount || 0} / 3
))}
) : (

All projects have sufficient assignments

)}
) } // ── Jury Progress Table ────────────────────────────────────────────────── function JuryProgressTable({ roundId }: { roundId: string }) { const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery( { roundId }, { refetchInterval: 15_000 }, ) return ( Jury Progress Evaluation completion per juror {isLoading ? (
{[1, 2, 3].map((i) => )}
) : !workload || workload.length === 0 ? (

No assignments yet

) : (
{workload.map((juror) => { const pct = juror.completionRate const barGradient = pct === 100 ? 'bg-gradient-to-r from-emerald-400 to-emerald-600' : pct >= 50 ? 'bg-gradient-to-r from-blue-400 to-blue-600' : pct > 0 ? 'bg-gradient-to-r from-amber-400 to-amber-600' : 'bg-gray-300' return (
{juror.name} {juror.completed}/{juror.assigned} ({pct}%)
) })}
)} ) } // ── Score Distribution ─────────────────────────────────────────────────── function ScoreDistribution({ roundId }: { roundId: string }) { const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery( { roundId }, { refetchInterval: 15_000 }, ) const maxCount = useMemo(() => dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1, [dist]) return ( Score Distribution {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'} {isLoading ? (
{Array.from({ length: 10 }).map((_, i) => )}
) : !dist || dist.totalEvaluations === 0 ? (

No evaluations submitted yet

) : (
{dist.globalDistribution.map((bucket) => { const heightPct = (bucket.count / maxCount) * 100 return (
{bucket.count || ''}
{bucket.score}
) })}
)} ) } // ── Send Reminders Button ──────────────────────────────────────────────── function SendRemindersButton({ roundId }: { roundId: string }) { const [open, setOpen] = useState(false) const mutation = trpc.evaluation.triggerReminders.useMutation({ onSuccess: (data) => { toast.success(`Sent ${data.sent} reminder(s)`) setOpen(false) }, onError: (err) => toast.error(err.message), }) return ( <> Send evaluation reminders? This will send reminder emails to all jurors who have incomplete evaluations for this round. Cancel mutation.mutate({ roundId })} disabled={mutation.isPending} > {mutation.isPending && } Send Reminders ) } // ── Export Evaluations Dialog ───────────────────────────────────────────── function ExportEvaluationsDialog({ roundId, open, onOpenChange, }: { roundId: string open: boolean onOpenChange: (open: boolean) => void }) { const [exportData, setExportData] = useState(undefined) const [isLoadingExport, setIsLoadingExport] = useState(false) const utils = trpc.useUtils() const handleRequestData = async () => { setIsLoadingExport(true) try { const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true }) setExportData(data) return data } finally { setIsLoadingExport(false) } } return ( ) } // ── Individual Assignments Table ───────────────────────────────────────── function IndividualAssignmentsTable({ roundId, projectStates, }: { roundId: string projectStates: any[] | undefined }) { const [addDialogOpen, setAddDialogOpen] = useState(false) const [selectedJurorId, setSelectedJurorId] = useState('') const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set()) const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false) const [projectSearch, setProjectSearch] = useState('') const utils = trpc.useUtils() const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery( { roundId }, { refetchInterval: 15_000 }, ) const { data: juryMembers } = trpc.user.getJuryMembers.useQuery( { roundId }, { enabled: addDialogOpen }, ) const deleteMutation = trpc.assignment.delete.useMutation({ onSuccess: () => { utils.assignment.listByStage.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) toast.success('Assignment removed') }, onError: (err) => toast.error(err.message), }) const createMutation = trpc.assignment.create.useMutation({ onSuccess: () => { utils.assignment.listByStage.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.user.getJuryMembers.invalidate({ roundId }) toast.success('Assignment created') resetDialog() }, onError: (err) => toast.error(err.message), }) const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({ onSuccess: (result) => { utils.assignment.listByStage.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.user.getJuryMembers.invalidate({ roundId }) toast.success(`${result.created} assignment(s) created`) resetDialog() }, onError: (err) => toast.error(err.message), }) const resetDialog = useCallback(() => { setAddDialogOpen(false) setSelectedJurorId('') setSelectedProjectIds(new Set()) setProjectSearch('') }, []) const selectedJuror = useMemo( () => juryMembers?.find((j: any) => j.id === selectedJurorId), [juryMembers, selectedJurorId], ) // Filter projects by search term const filteredProjects = useMemo(() => { const items = projectStates ?? [] if (!projectSearch) return items const q = projectSearch.toLowerCase() return items.filter((ps: any) => ps.project?.title?.toLowerCase().includes(q) || ps.project?.teamName?.toLowerCase().includes(q) || ps.project?.competitionCategory?.toLowerCase().includes(q) ) }, [projectStates, projectSearch]) // Existing assignments for the selected juror (to grey out already-assigned projects) const jurorExistingProjectIds = useMemo(() => { if (!selectedJurorId || !assignments) return new Set() return new Set( assignments .filter((a: any) => a.userId === selectedJurorId) .map((a: any) => a.projectId) ) }, [selectedJurorId, assignments]) const toggleProject = useCallback((projectId: string) => { setSelectedProjectIds(prev => { const next = new Set(prev) if (next.has(projectId)) { next.delete(projectId) } else { next.add(projectId) } return next }) }, []) const selectAllUnassigned = useCallback(() => { const unassigned = filteredProjects .filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id)) .map((ps: any) => ps.project?.id) .filter(Boolean) setSelectedProjectIds(new Set(unassigned)) }, [filteredProjects, jurorExistingProjectIds]) const handleCreate = useCallback(() => { if (!selectedJurorId || selectedProjectIds.size === 0) return const projectIds = Array.from(selectedProjectIds) if (projectIds.length === 1) { createMutation.mutate({ userId: selectedJurorId, projectId: projectIds[0], roundId, }) } else { bulkCreateMutation.mutate({ roundId, assignments: projectIds.map(projectId => ({ userId: selectedJurorId, projectId, })), }) } }, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation]) const isMutating = createMutation.isPending || bulkCreateMutation.isPending return (
All Assignments {assignments?.length ?? 0} individual jury-project assignments
{isLoading ? (
{[1, 2, 3, 4, 5].map((i) => )}
) : !assignments || assignments.length === 0 ? (

No assignments yet. Generate assignments or add one manually.

) : (
Juror Project Status
{assignments.map((a: any, idx: number) => (
{a.user?.name || a.user?.email || 'Unknown'} {a.project?.title || 'Unknown'} {a.evaluation?.status || 'PENDING'}
))}
)}
{/* Add Assignment Dialog */} { if (!open) resetDialog() else setAddDialogOpen(true) }}> Add Assignment Select a juror and one or more projects to assign
{/* Juror Selector */}
No jury members found. {juryMembers?.map((juror: any) => { const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0 return ( { setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id) setSelectedProjectIds(new Set()) setJurorPopoverOpen(false) }} >

{juror.name || 'Unnamed'}

{juror.email}

{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'} {atCapacity ? ' full' : ''}
) })}
{/* Project Multi-Select */}
{selectedJurorId && (
{selectedProjectIds.size > 0 && ( )}
)}
{/* Search input */}
setProjectSearch(e.target.value)} className="pl-9 h-9" />
{/* Project checklist */}
{!selectedJurorId ? (

Select a juror first

) : filteredProjects.length === 0 ? (

No projects found

) : ( filteredProjects.map((ps: any) => { const project = ps.project if (!project) return null const alreadyAssigned = jurorExistingProjectIds.has(project.id) const isSelected = selectedProjectIds.has(project.id) return ( ) }) )}
) } // ── Evaluation Criteria Editor ─────────────────────────────────────────── // ── Advance Projects Dialog ───────────────────────────────────────────── function AdvanceProjectsDialog({ open, onOpenChange, roundId, projectStates, config, advanceMutation, competitionRounds, currentSortOrder, }: { open: boolean onOpenChange: (open: boolean) => void roundId: string projectStates: any[] | undefined config: Record advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean } competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }> currentSortOrder?: number }) { // Target round selector const availableTargets = useMemo(() => (competitionRounds ?? []) .filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId) .sort((a, b) => a.sortOrder - b.sortOrder), [competitionRounds, currentSortOrder, roundId]) const [targetRoundId, setTargetRoundId] = useState('') // Default to first available target when dialog opens if (open && !targetRoundId && availableTargets.length > 0) { setTargetRoundId(availableTargets[0].id) } const passedProjects = useMemo(() => (projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'), [projectStates]) const startups = useMemo(() => passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'), [passedProjects]) const concepts = useMemo(() => passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'), [passedProjects]) const other = useMemo(() => passedProjects.filter((ps: any) => ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT', ), [passedProjects]) const startupCap = (config.startupAdvanceCount as number) || 0 const conceptCap = (config.conceptAdvanceCount as number) || 0 const [selected, setSelected] = useState>(new Set()) // Reset selection when dialog opens if (open && selected.size === 0 && passedProjects.length > 0) { const initial = new Set() // Auto-select all (or up to cap if configured) const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts for (const ps of startupSlice) initial.add(ps.project?.id) for (const ps of conceptSlice) initial.add(ps.project?.id) for (const ps of other) initial.add(ps.project?.id) setSelected(initial) } const toggleProject = (projectId: string) => { setSelected((prev) => { const next = new Set(prev) if (next.has(projectId)) next.delete(projectId) else next.add(projectId) return next }) } const toggleAll = (projects: any[], on: boolean) => { setSelected((prev) => { const next = new Set(prev) for (const ps of projects) { if (on) next.add(ps.project?.id) else next.delete(ps.project?.id) } return next }) } const handleAdvance = () => { const ids = Array.from(selected) if (ids.length === 0) return advanceMutation.mutate({ roundId, projectIds: ids, ...(targetRoundId ? { targetRoundId } : {}), }) onOpenChange(false) setSelected(new Set()) setTargetRoundId('') } const handleClose = () => { onOpenChange(false) setSelected(new Set()) setTargetRoundId('') } const renderCategorySection = ( label: string, projects: any[], cap: number, badgeColor: string, ) => { const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length const overCap = cap > 0 && selectedInCategory > cap return (
0 && projects.every((ps: any) => selected.has(ps.project?.id))} onCheckedChange={(checked) => toggleAll(projects, !!checked)} /> {label} {selectedInCategory}/{projects.length} {cap > 0 && ( (target: {cap}) )}
{projects.length === 0 ? (

No passed projects in this category

) : (
{projects.map((ps: any) => ( ))}
)}
) } return ( Advance Projects Select which passed projects to advance. {selected.size} of {passedProjects.length} selected. {/* Target round selector */} {availableTargets.length > 0 && (
)} {availableTargets.length === 0 && (
No subsequent rounds found. Projects will advance to the next round by sort order.
)}
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')} {renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')} {other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
) } // ── AI Recommendations Display ────────────────────────────────────────── type RecommendationItem = { projectId: string rank: number score: number category: string strengths: string[] concerns: string[] recommendation: string } function AIRecommendationsDisplay({ recommendations, onClear, }: { recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] } onClear: () => void }) { const [expandedId, setExpandedId] = useState(null) const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => { if (items.length === 0) return (
No {label.toLowerCase()} projects evaluated
) return (
{items.map((item) => { const isExpanded = expandedId === `${item.category}-${item.projectId}` return (
{isExpanded && (

Strengths

    {item.strengths.map((s, i) =>
  • {s}
  • )}
{item.concerns.length > 0 && (

Concerns

    {item.concerns.map((c, i) =>
  • {c}
  • )}
)}

Recommendation

{item.recommendation}

)}
) })}
) } return (
AI Shortlist Recommendations Ranked independently per category — {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts

Startup ({recommendations.STARTUP.length})

{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}

Business Concept ({recommendations.BUSINESS_CONCEPT.length})

{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
) } // ── Evaluation Criteria Editor ─────────────────────────────────────────── function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { const [editing, setEditing] = useState(false) const [criteria, setCriteria] = useState>([]) const utils = trpc.useUtils() const { data: form, isLoading } = trpc.evaluation.getForm.useQuery( { roundId }, { refetchInterval: 30_000 }, ) const upsertMutation = trpc.evaluation.upsertForm.useMutation({ onSuccess: () => { utils.evaluation.getForm.invalidate({ roundId }) toast.success('Evaluation criteria saved') setEditing(false) }, onError: (err) => toast.error(err.message), }) // Sync from server if (form && !editing) { const serverCriteria = form.criteriaJson ?? [] if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) { setCriteria(serverCriteria) } } const handleAdd = () => { setCriteria([...criteria, { id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label: '', description: '', weight: 1, minScore: 0, maxScore: 10, }]) setEditing(true) } const handleRemove = (id: string) => { setCriteria(criteria.filter((c) => c.id !== id)) } const handleChange = (id: string, field: string, value: string | number) => { setCriteria(criteria.map((c) => c.id === id ? { ...c, [field]: value } : c, )) setEditing(true) } const handleSave = () => { const validCriteria = criteria.filter((c) => c.label.trim()) if (validCriteria.length === 0) { toast.error('Add at least one criterion') return } upsertMutation.mutate({ roundId, criteria: validCriteria }) } return (
Evaluation Criteria {form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'}
{editing && ( )} {editing ? ( ) : ( )}
{isLoading ? (
{[1, 2, 3].map((i) => )}
) : criteria.length === 0 ? (

No evaluation criteria defined

Add criteria that jurors will use to score projects

) : (
{criteria.map((c, idx) => (
{idx + 1}
handleChange(c.id, 'label', e.target.value)} className="h-8 text-sm" /> handleChange(c.id, 'description', e.target.value)} className="h-7 text-xs" />
handleChange(c.id, 'weight', Number(e.target.value))} className="h-7 text-xs" />
handleChange(c.id, 'minScore', Number(e.target.value))} className="h-7 text-xs" />
handleChange(c.id, 'maxScore', Number(e.target.value))} className="h-7 text-xs" />
))} {!editing && ( )}
)}
) }