'use client' import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useParams, useSearchParams } 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 { 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, ArrowRightLeft, Save, Loader2, ChevronDown, ChevronRight, Play, Square, Archive, Layers, Users, CalendarDays, BarChart3, ClipboardList, Settings, Zap, Shield, Mail, Shuffle, UserPlus, CheckCircle2, AlertTriangle, Trophy, Download, Plus, Trash2, ArrowRight, RotateCcw, } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' 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 { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' 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' import { roundTypeConfig as sharedRoundTypeConfig, roundStatusConfig as sharedRoundStatusConfig, projectStateConfig, } from '@/lib/round-config' import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap' import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue' import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table' import { TransferAssignmentsDialog } from '@/components/admin/assignment/transfer-assignments-dialog' import { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history' import { ScoreDistribution } from '@/components/admin/round/score-distribution' import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button' import { NotifyJurorsButton } from '@/components/admin/assignment/notify-jurors-button' import { ExportEvaluationsDialog } from '@/components/admin/round/export-evaluations-dialog' import { IndividualAssignmentsTable } from '@/components/admin/assignment/individual-assignments-table' import { AdvanceProjectsDialog } from '@/components/admin/round/advance-projects-dialog' import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommendations-display' import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor' import { COIReviewSection } from '@/components/admin/assignment/coi-review-section' import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header' import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card' // ── Helpers ──────────────────────────────────────────────────────────────── function getRelativeTime(date: Date): string { const now = new Date() const diffMs = date.getTime() - now.getTime() const absDiffMs = Math.abs(diffMs) const minutes = Math.floor(absDiffMs / 60_000) const hours = Math.floor(absDiffMs / 3_600_000) const days = Math.floor(absDiffMs / 86_400_000) const label = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m` return diffMs > 0 ? `in ${label}` : `${label} ago` } // ── Status & type config maps (from shared lib) ──────────────────────────── const roundStatusConfig = sharedRoundStatusConfig const roundTypeConfig = sharedRoundTypeConfig const stateColors: Record = Object.fromEntries( Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg]) ) // ═══════════════════════════════════════════════════════════════════════════ // Main Page Component // ═══════════════════════════════════════════════════════════════════════════ export default function RoundDetailPage() { const params = useParams() const roundId = params.roundId as string const searchParams = useSearchParams() const backUrl = searchParams.get('from') const [config, setConfig] = useState>({}) const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [activeTab, setActiveTab] = useState('overview') const [previewSheetOpen, setPreviewSheetOpen] = useState(false) // AI assignment generation (lifted to page level so it persists when sheet closes) const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({ onSuccess: () => { toast.success('AI assignments ready!', { action: { label: 'Review', onClick: () => setPreviewSheetOpen(true), }, duration: 10000, }) }, onError: (err) => { toast.error(`AI generation failed: ${err.message}`, { duration: 15000 }) console.error('[AI Assignment]', err) }, }) 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 [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null) const [coverageOpen, setCoverageOpen] = 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) ?? [] // Jury workload (for assignments tab coverage auto-open logic) const { data: juryWorkload } = trpc.analytics.getJurorWorkload.useQuery( { roundId }, { enabled: round?.roundType === 'EVALUATION', refetchInterval: 15_000 }, ) // Filtering results stats (only for FILTERING rounds) const { data: filteringStats } = trpc.filtering.getResultStats.useQuery( { roundId }, { enabled: round?.roundType === 'FILTERING', refetchInterval: 5_000 }, ) // Initialize config from server on load; re-sync after saves const serverConfig = useMemo(() => (round?.configJson as Record) ?? {}, [round?.configJson]) const configInitialized = useRef(false) const savingRef = useRef(false) // Sync local config with server: on initial load AND whenever serverConfig // changes after a save completes (so Zod-applied defaults get picked up) useEffect(() => { if (!round) return if (!configInitialized.current) { configInitialized.current = true setConfig(serverConfig) } else if (!savingRef.current) { // Server changed (e.g. after save invalidation) — re-sync setConfig(serverConfig) } }, [serverConfig, round]) const hasUnsavedConfig = useMemo( () => configInitialized.current && JSON.stringify(config) !== JSON.stringify(serverConfig), [config, serverConfig], ) // Auto-open coverage section when no assignments exist yet useEffect(() => { if (juryWorkload && juryWorkload.length === 0) { setCoverageOpen(true) } }, [juryWorkload]) // ── Mutations ────────────────────────────────────────────────────────── const updateMutation = trpc.round.update.useMutation({ onSuccess: () => { savingRef.current = false utils.round.getById.invalidate({ id: roundId }) setAutosaveStatus('saved') setTimeout(() => setAutosaveStatus('idle'), 2000) }, onError: (err) => { savingRef.current = false setAutosaveStatus('error') 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 updateJuryMemberMutation = trpc.juryGroup.updateMember.useMutation({ onSuccess: () => { if (juryGroupId) utils.juryGroup.getById.invalidate({ id: juryGroupId }) toast.success('Cap updated') }, onError: (err) => toast.error(err.message), }) // Jury member quick actions (same as in JuryProgressTable) const [memberTransferJuror, setMemberTransferJuror] = useState<{ id: string; name: string } | null>(null) const notifyMemberMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({ onSuccess: (data) => { toast.success(`Notified juror of ${data.projectCount} assignment(s)`) }, onError: (err) => toast.error(err.message), }) const redistributeMemberMutation = trpc.assignment.redistributeJurorAssignments.useMutation({ onSuccess: (data) => { utils.assignment.listByStage.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.analytics.getJurorWorkload.invalidate({ roundId }) utils.roundAssignment.unassignedQueue.invalidate({ roundId }) if (data.failedCount > 0) { toast.warning(`Reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`) } else { toast.success(`Reassigned ${data.movedCount} project(s) to other jurors.`) } }, onError: (err) => toast.error(err.message), }) const reshuffleMemberMutation = trpc.assignment.reassignDroppedJuror.useMutation({ onSuccess: (data) => { utils.assignment.listByStage.invalidate({ roundId }) utils.roundEngine.getProjectStates.invalidate({ roundId }) utils.analytics.getJurorWorkload.invalidate({ roundId }) utils.roundAssignment.unassignedQueue.invalidate({ roundId }) if (data.failedCount > 0) { toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned.`) } else { toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`) } }, 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 }) const msg = data.autoPassedCount ? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}` : `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}` toast.success(msg) 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) }, []) const saveConfig = useCallback(() => { savingRef.current = true setAutosaveStatus('saving') updateMutation.mutate({ id: roundId, configJson: config }) }, [config, roundId, updateMutation]) // ── Auto-save: debounce config changes and save automatically ──────── const configJson = JSON.stringify(config) const serverJson = JSON.stringify(serverConfig) useEffect(() => { if (!configInitialized.current) return if (configJson === serverJson) return const timer = setTimeout(() => { savingRef.current = true setAutosaveStatus('saving') updateMutation.mutate({ id: roundId, configJson: config }) }, 800) return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, [configJson]) // ── Computed values ──────────────────────────────────────────────────── const projectCount = projectStates?.length ?? 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 = juryGroupDetail?.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 isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '') const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route // ── Loading state ────────────────────────────────────────────────────── if (isLoading) { return (
{/* Header skeleton — dark gradient placeholder */}
) } 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, }, ...((isEvaluation && !(config.requireDocumentUpload as boolean)) ? [] : [{ 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 ===== */}
{/* 4.6 Inline-editable round name */} {editingName ? ( setNameValue(e.target.value)} onBlur={() => { const trimmed = nameValue.trim() if (trimmed && trimmed !== round.name) { updateMutation.mutate({ id: roundId, name: trimmed }) } setEditingName(false) }} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur() } if (e.key === 'Escape') { setNameValue(round.name) setEditingName(false) } }} className="text-xl font-bold tracking-tight bg-white/10 border-white/30 text-white h-8 w-64" autoFocus /> ) : ( )} {typeCfg.label} {/* Status dropdown with confirmation dialogs (4.1) */} {status === 'ROUND_DRAFT' && ( setStatusConfirmAction('activate')} disabled={isTransitioning || readyCount < readinessItems.length} > Activate Round {readyCount < readinessItems.length && ( {readyCount}/{readinessItems.length} )} {readyCount < readinessItems.length && (

Not ready to activate:

    {readinessItems.filter((i) => !i.ready).map((item) => (
  • • {item.label}
  • ))}
)}
)} {status === 'ROUND_ACTIVE' && ( setStatusConfirmAction('close')} disabled={isTransitioning} > Close Round )} {status === 'ROUND_CLOSED' && ( <> setStatusConfirmAction('reopen')} disabled={isTransitioning} > Reopen Round setStatusConfirmAction('archive')} disabled={isTransitioning} > Archive Round )} {isTransitioning && (
Updating...
)}

{statusCfg.description}

{/* Status change confirmation dialog (4.1) */} { if (!open) setStatusConfirmAction(null) }}> {statusConfirmAction === 'activate' && 'Activate this round?'} {statusConfirmAction === 'close' && 'Close this round?'} {statusConfirmAction === 'reopen' && 'Reopen this round?'} {statusConfirmAction === 'archive' && 'Archive this round?'} {statusConfirmAction === 'activate' && ( <> The round will go live. Projects can be processed and jury members will be able to see their assignments. {readyCount < readinessItems.length && ( Warning: {readinessItems.length - readyCount} readiness item(s) not yet complete ({readinessItems.filter((i) => !i.ready).map((i) => i.label).join(', ')}). )} )} {statusConfirmAction === 'close' && 'No further changes will be accepted. You can reactivate later if needed.'} {statusConfirmAction === 'reopen' && 'The round will become active again. Any rounds after this one that are currently active will be paused automatically.'} {statusConfirmAction === 'archive' && 'The round will be archived. It will only be available as a historical record.'} Cancel { if (statusConfirmAction === 'activate') activateMutation.mutate({ roundId }) else if (statusConfirmAction === 'close') closeMutation.mutate({ roundId }) else if (statusConfirmAction === 'reopen') reopenMutation.mutate({ roundId }) else if (statusConfirmAction === 'archive') archiveMutation.mutate({ roundId }) setStatusConfirmAction(null) }} > {statusConfirmAction === 'activate' && 'Activate'} {statusConfirmAction === 'close' && 'Close Round'} {statusConfirmAction === 'reopen' && 'Reopen'} {statusConfirmAction === 'archive' && 'Archive'}

{typeCfg.description}

{(round.windowOpenAt || round.windowCloseAt) && (
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleDateString() : 'No start'} {' \u2014 '} {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleDateString() : 'No deadline'} {(() => { const now = new Date() const openAt = round.windowOpenAt ? new Date(round.windowOpenAt) : null const closeAt = round.windowCloseAt ? new Date(round.windowCloseAt) : null if (openAt && now < openAt) { return Opens {getRelativeTime(openAt)} } if (closeAt && now < closeAt) { return Closes {getRelativeTime(closeAt)} } if (closeAt && now >= closeAt) { return Closed {getRelativeTime(closeAt)} } return null })()}
)}
{/* Action buttons */}
{autosaveStatus === 'saved' && ( Saved )}
{/* ===== 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 & Jury', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []), ...(hasJury && !isEvaluation ? [{ 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 && ( )}
))}
{/* Advancement Votes Summary — only for EVALUATION rounds */} {isEvaluation && ( )} {/* Filtering Results Summary — only for FILTERING rounds with results */} {isFiltering && filteringStats && filteringStats.total > 0 && (
Filtering Results {filteringStats.total} projects evaluated

{filteringStats.passed}

Passed

{filteringStats.filteredOut}

Filtered Out

{filteringStats.flagged}

Flagged

{/* Progress bar showing pass rate */}
Pass rate {Math.round((filteringStats.passed / filteringStats.total) * 100)}%
{filteringStats.overridden > 0 && (

{filteringStats.overridden} result(s) manually overridden

)}
)} {/* 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)} onApplied={() => { setAiRecommendations(null) utils.roundEngine.getProjectStates.invalidate({ roundId }) }} /> )} {/* Round Info + Project Breakdown */}
Round Details {[ { label: 'Type', value: {typeCfg.label} }, { label: 'Status', value: {statusCfg.label} }, { label: 'Position', value: {`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`} }, ...(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 (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */} {hasJury && !isEvaluation && ( {/* 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}

updateJuryMemberMutation.mutate({ id: member.id, maxAssignmentsOverride: val, })} />

Notify juror of assignments

Reassign all pending projects to other jurors

Transfer specific assignments to other jurors

Drop juror & reshuffle pending projects

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
))}
)}
)} {/* Jury Group Selector (at bottom for non-evaluation jury rounds) */}
Jury Group Select or create a jury group for this round
{juryGroups && juryGroups.length > 0 ? (
{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.

)}
)} {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */} {isEvaluation && ( {/* 1. Jury Members & Progress (merged) */} {!round?.juryGroupId ? (

Select a jury group below to get started.

) : ( ({ id: m.id, userId: m.userId, name: m.user.name || 'Unnamed', email: m.user.email, maxAssignmentsOverride: m.maxAssignmentsOverride as number | null, }))} onSaveCap={(id, val) => updateJuryMemberMutation.mutate({ id, maxAssignmentsOverride: val })} onRemoveMember={(id) => removeJuryMemberMutation.mutate({ id })} onAddMember={() => setAddMemberOpen(true)} /> )} {/* 2. Score Distribution (full-width) */} {/* 3. Reassignment History (always open) */} {/* ── Remaining content only shown when jury group is assigned ── */} {round?.juryGroupId && ( <> {/* 4. Assignments */}
Assignments Individual jury-project assignments and actions
{/* 5. Monitoring — COI + Unassigned Queue */} Monitoring Conflict of interest declarations and unassigned projects {/* 6. Coverage & Generation (collapsible, default collapsed when assignments exist) */} setCoverageOpen((o) => !o)} > Coverage & Generation Assignment coverage overview and AI generation {coverageOpen && ( {/* Generate Assignments */}

Assignment Generation {aiAssignmentMutation.isPending && ( AI generating... )} {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( {aiAssignmentMutation.data.stats.assignmentsGenerated} ready )}

AI-suggested jury-to-project assignments based on expertise and workload

{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && ( )}
{projectCount === 0 && (
Add projects to this round first.
)} {juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (

Click "Generate with AI" to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.

)} {aiAssignmentMutation.isPending && (

AI is analyzing projects and jurors...

Matching expertise, reviewing bios, and balancing workloads

)} {aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (

AI generation failed

{aiAssignmentMutation.error.message}

)} {aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (

{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated

{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects {aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}

)}
)} {/* 7. Jury Group (at bottom) */}
Jury Group Select or create a jury group for this round
{juryGroups && juryGroups.length > 0 ? (
{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.

)}
{/* Assignment Preview Sheet */} aiAssignmentMutation.mutate({ roundId, requiredReviews: (config.requiredReviewsPerProject as number) || 3, })} onResetAI={() => aiAssignmentMutation.reset()} /> {/* CSV Export Dialog */} )} )} {/* ═══════════ RANKING TAB (EVALUATION rounds) ═══════════ */} {isEvaluation && ( )} {/* ═══════════ CONFIG TAB ═══════════ */} {/* Round Dates */} {!round.windowOpenAt && !round.windowCloseAt && (

Dates not set — this round cannot be activated without dates.

)}
updateMutation.mutate({ id: roundId, windowOpenAt: date }, { onSuccess: () => toast.success('Dates saved') })} placeholder="Select start date & time" clearable />
updateMutation.mutate({ id: roundId, windowCloseAt: date }, { onSuccess: () => toast.success('Dates saved') })} placeholder="Select end date & time" clearable />
{/* General Round Settings */}

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 }) }} />
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (

Advancement targets not configured — all passed projects will be eligible to advance.

)}

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 — hidden for EVALUATION rounds unless requireDocumentUpload is on */} {(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && ( 0 ? 'complete' : 'warning'} summary={ (fileRequirements?.length ?? 0) > 0 ? `${fileRequirements?.length} requirement(s)` : undefined } /> )}
{/* ═══════════ 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 return (

{award.name}

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

{award.description}

)}
{eligibleCount}
eligible
) })}
)}
)} {/* ── Page-level dialogs (shared between jury/assignments tabs) ── */} 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, ''), }) } }} />
{juryGroupId && ( { setAddMemberOpen(open) if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId }) }} /> )} {/* Autosave error bar — only shows when save fails */} {autosaveStatus === 'error' && (
Auto-save failed
)} {memberTransferJuror && ( setMemberTransferJuror(null)} /> )}
) }