Files
MOPC-Portal/src/app/(admin)/admin/rounds/[roundId]/page.tsx
Matt f26ee3f076
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00

2427 lines
117 KiB
TypeScript

'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,
Save,
Loader2,
ChevronDown,
Play,
Square,
Archive,
Layers,
Users,
CalendarDays,
BarChart3,
ClipboardList,
Settings,
Zap,
Shield,
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 { 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 { 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'
// ── 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<string, string> = 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<Record<string, unknown>>({})
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<HTMLInputElement>(null)
const [statusConfirmAction, setStatusConfirmAction] = useState<'activate' | 'close' | 'reopen' | 'archive' | null>(null)
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) ?? []
// 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<string, unknown>) ?? {}, [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],
)
// ── 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),
})
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<string, unknown>) => {
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<string, number>, ps: any) => {
acc[ps.state] = (acc[ps.state] || 0) + 1
return acc
}, {} as Record<string, number>) ?? {},
[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 (
<div className="space-y-6">
{/* Header skeleton — dark gradient placeholder */}
<div className="rounded-xl bg-gradient-to-r from-[#053d57]/20 to-[#0a5a7c]/20 p-6 animate-pulse">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded bg-white/20" />
<div className="space-y-2">
<Skeleton className="h-7 w-64 bg-white/20" />
<Skeleton className="h-4 w-40 bg-white/20" />
</div>
</div>
</div>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-28 rounded-lg" />)}
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
)
}
if (!round) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={'/admin/rounds' as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">This round does not exist.</p>
</div>
</div>
</div>
)
}
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 (
<div className="space-y-6">
{/* ===== HEADER — Dark Blue gradient banner ===== */}
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="rounded-xl bg-gradient-to-r from-[#053d57] to-[#0a5a7c] p-5 sm:p-6 text-white shadow-lg"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={(backUrl ?? (round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds')) as Route} className="mt-0.5 shrink-0">
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
<ArrowLeft className="h-4 w-4" />
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
{/* 4.6 Inline-editable round name */}
{editingName ? (
<Input
ref={nameInputRef}
value={nameValue}
onChange={(e) => 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
/>
) : (
<button
type="button"
className="text-xl font-bold tracking-tight truncate hover:bg-white/10 rounded px-1 -mx-1 transition-colors cursor-text"
onClick={() => {
setNameValue(round.name)
setEditingName(true)
setTimeout(() => nameInputRef.current?.focus(), 0)
}}
title="Click to edit round name"
>
{round.name}
</button>
)}
<Badge variant="secondary" className="text-xs shrink-0 bg-white/15 text-white border-white/20 hover:bg-white/20">
{typeCfg.label}
</Badge>
{/* Status dropdown with confirmation dialogs (4.1) */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-full transition-colors shrink-0',
'bg-white/15 text-white hover:bg-white/25',
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dotClass)} />
{statusCfg.label}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{status === 'ROUND_DRAFT' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<DropdownMenuItem
onClick={() => setStatusConfirmAction('activate')}
disabled={isTransitioning || readyCount < readinessItems.length}
>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Activate Round
{readyCount < readinessItems.length && (
<span className="ml-auto text-[10px] text-muted-foreground">
{readyCount}/{readinessItems.length}
</span>
)}
</DropdownMenuItem>
</span>
</TooltipTrigger>
{readyCount < readinessItems.length && (
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs font-medium mb-1">Not ready to activate:</p>
<ul className="space-y-0.5">
{readinessItems.filter((i) => !i.ready).map((item) => (
<li key={item.label} className="text-xs">&bull; {item.label}</li>
))}
</ul>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{status === 'ROUND_ACTIVE' && (
<DropdownMenuItem
onClick={() => setStatusConfirmAction('close')}
disabled={isTransitioning}
>
<Square className="h-4 w-4 mr-2 text-blue-600" />
Close Round
</DropdownMenuItem>
)}
{status === 'ROUND_CLOSED' && (
<>
<DropdownMenuItem
onClick={() => setStatusConfirmAction('reopen')}
disabled={isTransitioning}
>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Reopen Round
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatusConfirmAction('archive')}
disabled={isTransitioning}
>
<Archive className="h-4 w-4 mr-2" />
Archive Round
</DropdownMenuItem>
</>
)}
{isTransitioning && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Updating...
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{statusCfg.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Status change confirmation dialog (4.1) */}
<AlertDialog open={!!statusConfirmAction} onOpenChange={(open) => { if (!open) setStatusConfirmAction(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{statusConfirmAction === 'activate' && 'Activate this round?'}
{statusConfirmAction === 'close' && 'Close this round?'}
{statusConfirmAction === 'reopen' && 'Reopen this round?'}
{statusConfirmAction === 'archive' && 'Archive this round?'}
</AlertDialogTitle>
<AlertDialogDescription>
{statusConfirmAction === 'activate' && (
<>
The round will go live. Projects can be processed and jury members will be able to see their assignments.
{readyCount < readinessItems.length && (
<span className="block mt-2 text-amber-600">
Warning: {readinessItems.length - readyCount} readiness item(s) not yet complete ({readinessItems.filter((i) => !i.ready).map((i) => i.label).join(', ')}).
</span>
)}
</>
)}
{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.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
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'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-sm text-white/60 mt-1">{typeCfg.description}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{autosaveStatus === 'saved' && (
<span className="flex items-center gap-1.5 text-xs text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
Saved
</span>
)}
<Link href={poolLink}>
<Button variant="outline" size="sm" className="border-white/40 bg-white/15 text-white hover:bg-white/30 hover:text-white">
<Layers className="h-4 w-4 mr-1.5" />
Project Pool
</Button>
</Link>
</div>
</div>
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-[#557f8c]/10 p-1.5">
<Layers className="h-4 w-4 text-[#557f8c]" />
</div>
<span className="text-sm font-medium text-muted-foreground">Projects</span>
</div>
<p className="text-3xl font-bold mt-2">{projectCount}</p>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{Object.entries(stateCounts).map(([state, count]) => (
<span key={state} className="text-[10px] text-muted-foreground">
{String(count)} {state.toLowerCase().replace('_', ' ')}
</span>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
<div className="rounded-full bg-purple-50 p-1.5">
<Users className="h-4 w-4 text-purple-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Jury</span>
</div>
{juryGroup ? (
<>
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
<button
type="button"
className="text-[10px] text-[#557f8c] hover:underline shrink-0"
onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Change
</button>
</div>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<button
type="button"
className="text-xs text-[#557f8c] hover:underline"
onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Assign jury group
</button>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={hasJury ? 2 : 1}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-emerald-50 p-1.5">
<CalendarDays className="h-4 w-4 text-emerald-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Window</span>
</div>
{round.windowOpenAt || round.windowCloseAt ? (
<>
<p className="text-sm font-bold mt-2">
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString()
: 'No start'}
</p>
<p className="text-xs text-muted-foreground">
{round.windowCloseAt
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
: 'No deadline'}
</p>
{(() => {
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 <p className="text-[10px] text-[#557f8c] font-medium mt-0.5">Opens {getRelativeTime(openAt)}</p>
}
if (closeAt && now < closeAt) {
return <p className="text-[10px] text-amber-600 font-medium mt-0.5">Closes {getRelativeTime(closeAt)}</p>
}
if (closeAt && now >= closeAt) {
return <p className="text-[10px] text-muted-foreground mt-0.5">Closed {getRelativeTime(closeAt)}</p>
}
return null
})()}
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No dates set</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={hasJury ? 3 : 2}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
<div className="rounded-full bg-amber-50 p-1.5">
<BarChart3 className="h-4 w-4 text-amber-500" />
</div>
<span className="text-sm font-medium text-muted-foreground">Advancement</span>
</div>
{round.advancementRules && round.advancementRules.length > 0 ? (
<>
<p className="text-3xl font-bold mt-2">{round.advancementRules.length}</p>
<p className="text-xs text-muted-foreground">
{round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
</p>
</>
) : (
<>
<p className="text-3xl font-bold mt-2 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">Admin selection</p>
</>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* ===== TABS — Underline style ===== */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<div className="border-b overflow-x-auto">
<TabsList className="bg-transparent h-auto p-0 gap-0 w-full sm:w-auto">
{[
{ 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 }] : []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={cn(
'relative rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium transition-all',
'data-[state=active]:border-b-[#de0f1e] data-[state=active]:text-[#053d57] data-[state=active]:font-semibold data-[state=active]:shadow-none',
'text-muted-foreground hover:text-foreground',
'bg-transparent data-[state=active]:bg-transparent',
)}
>
<tab.icon className={cn('h-3.5 w-3.5 mr-1.5', activeTab === tab.value ? 'text-[#557f8c]' : '')} />
{tab.label}
{tab.value === 'awards' && roundAwards.length > 0 && (
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 text-[10px] px-1.5 bg-[#de0f1e] text-white">
{roundAwards.length}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</div>
{/* ═══════════ OVERVIEW TAB ═══════════ */}
<TabsContent value="overview" className="space-y-6">
{/* Readiness Checklist with Progress Ring */}
<AnimatedCard index={0}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* SVG Progress Ring */}
<div className="relative h-14 w-14 shrink-0">
<svg className="h-14 w-14 -rotate-90" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="currentColor" strokeWidth="3" className="text-muted/30" />
<circle
cx="28" cy="28" r="24" fill="none"
strokeWidth="3" strokeLinecap="round"
stroke={readyCount === readinessItems.length ? '#10b981' : '#de0f1e'}
strokeDasharray={`${(readyCount / readinessItems.length) * 150.8} 150.8`}
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold">
{readyCount}/{readinessItems.length}
</span>
</div>
<div>
<CardTitle className="text-base">Launch Readiness</CardTitle>
<CardDescription>
{readyCount === readinessItems.length
? 'All checks passed — ready to go'
: `${readinessItems.length - readyCount} item(s) remaining`}
</CardDescription>
</div>
</div>
<Badge
variant={readyCount === readinessItems.length ? 'default' : 'secondary'}
className={cn(
'text-xs',
readyCount === readinessItems.length
? 'bg-emerald-100 text-emerald-700'
: 'bg-amber-100 text-amber-700',
)}
>
{readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{readinessItems.map((item) => (
<div key={item.label} className="flex items-start gap-3">
{item.ready ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5 shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium', item.ready && 'text-emerald-600 opacity-80')}>
{item.label}
</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
{item.action && (
<Link href={item.action}>
<Button size="sm" className="shrink-0 text-xs bg-[#de0f1e] hover:bg-[#c00d1a] text-white">
{item.actionLabel}
</Button>
</Link>
)}
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Filtering Results Summary — only for FILTERING rounds with results */}
{isFiltering && filteringStats && filteringStats.total > 0 && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-full bg-purple-50 p-2">
<Shield className="h-5 w-5 text-purple-600" />
</div>
<div>
<CardTitle className="text-base">Filtering Results</CardTitle>
<CardDescription>
{filteringStats.total} projects evaluated
</CardDescription>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setActiveTab('filtering')}
>
View Details
<ArrowRight className="h-3.5 w-3.5 ml-1.5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 rounded-lg bg-emerald-50">
<p className="text-2xl font-bold text-emerald-700">{filteringStats.passed}</p>
<p className="text-xs text-emerald-600 font-medium">Passed</p>
</div>
<div className="text-center p-3 rounded-lg bg-red-50">
<p className="text-2xl font-bold text-red-700">{filteringStats.filteredOut}</p>
<p className="text-xs text-red-600 font-medium">Filtered Out</p>
</div>
<div className="text-center p-3 rounded-lg bg-amber-50">
<p className="text-2xl font-bold text-amber-700">{filteringStats.flagged}</p>
<p className="text-xs text-amber-600 font-medium">Flagged</p>
</div>
</div>
{/* Progress bar showing pass rate */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Pass rate</span>
<span>{Math.round((filteringStats.passed / filteringStats.total) * 100)}%</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden flex">
<div
className="bg-emerald-500 transition-all duration-500"
style={{ width: `${(filteringStats.passed / filteringStats.total) * 100}%` }}
/>
<div
className="bg-red-400 transition-all duration-500"
style={{ width: `${(filteringStats.filteredOut / filteringStats.total) * 100}%` }}
/>
<div
className="bg-amber-400 transition-all duration-500"
style={{ width: `${(filteringStats.flagged / filteringStats.total) * 100}%` }}
/>
</div>
{filteringStats.overridden > 0 && (
<p className="text-xs text-muted-foreground">
{filteringStats.overridden} result(s) manually overridden
</p>
)}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick Actions — Grouped & styled */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>
<CardDescription>Common operations for this round</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Round Control Group */}
{(status === 'ROUND_DRAFT' || status === 'ROUND_ACTIVE' || status === 'ROUND_CLOSED') && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Round Control</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{status === 'ROUND_DRAFT' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<Play className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Activate Round</p>
<p className="text-xs text-muted-foreground mt-0.5">
Start this round and allow project processing
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate this round?</AlertDialogTitle>
<AlertDialogDescription>
The round will go live. Projects can be processed and jury members will be able to see their assignments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => activateMutation.mutate({ roundId })}>
Activate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{status === 'ROUND_ACTIVE' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<Square className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close Round</p>
<p className="text-xs text-muted-foreground mt-0.5">
Stop accepting changes and finalize results
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Close this round?</AlertDialogTitle>
<AlertDialogDescription>
No further changes will be accepted. You can reactivate later if needed.
{projectCount > 0 && (
<span className="block mt-2">
{projectCount} projects are currently in this round.
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => closeMutation.mutate({ roundId })}>
Close Round
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{status === 'ROUND_CLOSED' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-amber-500 bg-amber-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<RotateCcw className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Reopen Round</p>
<p className="text-xs text-muted-foreground mt-0.5">
Reactivate this round. Any subsequent active rounds will be paused.
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reopen this round?</AlertDialogTitle>
<AlertDialogDescription>
The round will become active again. Any rounds after this one that are currently active will be paused (closed) automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => reopenMutation.mutate({ roundId })}>
Reopen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)}
{/* Project Management Group */}
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Project Management</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full">
<Layers className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Add projects from the pool to this round
</p>
</div>
</button>
</Link>
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<BarChart3 className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Manage Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
View, filter, and transition project states
</p>
</div>
</button>
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
<button
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
(isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
{isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
{/* Jury assignment for rounds that use jury */}
{hasJury && !juryGroup && (
<button
onClick={() => {
const el = document.querySelector('[data-jury-select]')
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-amber-500 bg-amber-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<UserPlus className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Assign Jury Group</p>
<p className="text-xs text-muted-foreground mt-0.5">
No jury group assigned. Select one in the Jury card above.
</p>
</div>
</button>
)}
{/* Evaluation: manage assignments */}
{isEvaluation && (
<button
onClick={() => setActiveTab('assignments')}
className="flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<ClipboardList className="h-5 w-5 text-[#557f8c] mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Manage Assignments</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate and review jury-project assignments
</p>
</div>
</button>
)}
</div>
</div>
{/* AI Tools Group */}
{((isFiltering || isEvaluation) && projectCount > 0) && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">AI Tools</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{isFiltering && (
<button
onClick={() => setActiveTab('filtering')}
className="flex items-start gap-3 p-4 rounded-lg border bg-gradient-to-br from-purple-50/50 to-blue-50/50 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Shield className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Run AI Filtering</p>
<p className="text-xs text-muted-foreground mt-0.5">
Screen projects with AI and manual review
</p>
</div>
</button>
)}
<button
onClick={() => setShortlistDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border bg-gradient-to-br from-purple-50/50 to-blue-50/50 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
disabled={shortlistMutation.isPending}
>
<Zap className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{shortlistMutation.isPending ? 'Generating...' : 'AI Recommendations'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Generate ranked shortlist per category using AI analysis
</p>
</div>
</button>
</div>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
roundType={round?.roundType}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
competitionRounds={competition?.rounds?.map((r: any) => ({
id: r.id,
name: r.name,
sortOrder: r.sortOrder,
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Generate AI Recommendations?</AlertDialogTitle>
<AlertDialogDescription>
The AI will analyze all project evaluations and generate a ranked shortlist
for each category independently.
{config.startupAdvanceCount ? (
<span className="block mt-1">
Startup target: top {String(config.startupAdvanceCount)}
</span>
) : null}
{config.conceptAdvanceCount ? (
<span className="block">
Business Concept target: top {String(config.conceptAdvanceCount)}
</span>
) : null}
{config.aiParseFiles ? (
<span className="block mt-1 text-amber-600">
Document parsing is enabled the AI will read uploaded file contents.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => shortlistMutation.mutate({ roundId })}
disabled={shortlistMutation.isPending}
>
{shortlistMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Generate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Recommendations Display */}
{aiRecommendations && (
<AIRecommendationsDisplay
recommendations={aiRecommendations}
projectStates={projectStates}
roundId={roundId}
onClear={() => setAiRecommendations(null)}
onApplied={() => {
setAiRecommendations(null)
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
)}
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-sm">Round Details</CardTitle>
</CardHeader>
<CardContent className="space-y-0 text-sm">
{[
{ label: 'Type', value: <Badge variant="secondary" className={cn('text-xs', typeCfg.badgeClass)}>{typeCfg.label}</Badge> },
{ label: 'Status', value: <span className="font-medium">{statusCfg.label}</span> },
{ label: 'Position', value: <span className="font-medium">{`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`}</span> },
...(round.purposeKey ? [{ label: 'Purpose', value: <span className="font-medium">{round.purposeKey}</span> }] : []),
{ label: 'Jury Group', value: <span className="font-medium">{juryGroup ? juryGroup.name : '\u2014'}</span> },
{ label: 'Opens', value: <span className="font-medium">{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}</span> },
{ label: 'Closes', value: <span className="font-medium">{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}</span> },
].map((row, i) => (
<div key={row.label} className={cn('flex justify-between items-center py-2.5', i > 0 && 'border-t border-dotted border-muted')}>
<span className="text-muted-foreground">{row.label}</span>
{row.value}
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Project Breakdown</CardTitle>
{projectCount > 0 && (
<span className="text-xs font-mono text-muted-foreground">{projectCount} total</span>
)}
</div>
</CardHeader>
<CardContent>
{projectCount === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects assigned yet
</p>
) : (
<div className="space-y-3">
{['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 (
<div key={state}>
<div className="flex justify-between text-xs mb-1.5">
<span className="text-muted-foreground capitalize font-medium">{state.toLowerCase().replace('_', ' ')}</span>
<span className="font-bold tabular-nums">{count} <span className="font-normal text-muted-foreground">({pct}%)</span></span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', stateColors[state])}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</TabsContent>
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}
{isFiltering && (
<TabsContent value="filtering" className="space-y-4">
<FilteringDashboard competitionId={competitionId} roundId={roundId} />
</TabsContent>
)}
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
{hasJury && !isEvaluation && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{juryGroups && juryGroups.length > 0 ? (
<div className="space-y-4">
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
{/* Delete button for currently selected jury group */}
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-1.5" />
Delete &quot;{juryGroup?.name}&quot;
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{juryGroup?.name}&quot; and remove all its members.
Rounds using this jury group will be unlinked. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJuryMutation.mutate({ id: round.juryGroupId! })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteJuryMutation.isPending}
>
{deleteJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Delete Jury
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-purple-50 p-4 mb-4">
<Users className="h-8 w-8 text-purple-400" />
</div>
<p className="text-sm font-medium">No Jury Groups</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Create a jury group to assign members who will evaluate projects in this round.
</p>
<Button size="sm" className="mt-4" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Create First Jury
</Button>
</div>
)}
</CardContent>
</Card>
{/* Members list (only if a jury group is assigned) */}
{juryGroupDetail && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
Members &mdash; {juryGroupDetail.name}
</CardTitle>
<CardDescription>
{juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{juryGroupDetail.members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<UserPlus className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Members Yet</p>
<p className="text-xs text-muted-foreground mt-1">
Add jury members to start assigning projects for evaluation.
</p>
<Button size="sm" variant="outline" className="mt-4" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add First Member
</Button>
</div>
) : (
<div className="space-y-1">
{juryGroupDetail.members.map((member: any, idx: number) => (
<div
key={member.id}
className={cn(
'flex items-center justify-between py-2.5 px-3 rounded-md transition-colors',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{member.user.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride as number | null}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => updateJuryMemberMutation.mutate({
id: member.id,
maxAssignmentsOverride: val,
})}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.user.name || member.user.email} from {juryGroupDetail.name}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeJuryMemberMutation.mutate({ id: member.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Create Jury Dialog */}
<Dialog open={createJuryOpen} onOpenChange={setCreateJuryOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. It will be automatically assigned to this round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input
placeholder="e.g. Round 1 Jury, Expert Panel, Final Jury"
value={newJuryName}
onChange={(e) => 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, ''),
})
}
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateJuryOpen(false)}>Cancel</Button>
<Button
onClick={() => {
createJuryMutation.mutate({
competitionId,
name: newJuryName.trim(),
slug: newJuryName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
})
}}
disabled={createJuryMutation.isPending || !newJuryName.trim()}
>
{createJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Member Dialog */}
{juryGroupId && (
<AddMemberDialog
juryGroupId={juryGroupId}
open={addMemberOpen}
onOpenChange={(open) => {
setAddMemberOpen(open)
if (!open) utils.juryGroup.getById.invalidate({ id: juryGroupId })
}}
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">
{/* ── Jury Group Selector (merged from Jury tab for EVALUATION) ── */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Jury Group</CardTitle>
<CardDescription>
Select or create a jury group for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
New Jury
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{juryGroups && juryGroups.length > 0 ? (
<div className="space-y-4">
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Select jury group..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No jury assigned</SelectItem>
{juryGroups.map((jg: any) => (
<SelectItem key={jg.id} value={jg.id}>
{jg.name} ({jg._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
{/* Delete button for currently selected jury group */}
{round.juryGroupId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4 mr-1.5" />
Delete &quot;{juryGroup?.name}&quot;
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete jury group?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{juryGroup?.name}&quot; and remove all its members.
Rounds using this jury group will be unlinked. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJuryMutation.mutate({ id: round.juryGroupId! })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteJuryMutation.isPending}
>
{deleteJuryMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Delete Jury
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-purple-50 p-4 mb-4">
<Users className="h-8 w-8 text-purple-400" />
</div>
<p className="text-sm font-medium">No Jury Groups</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Create a jury group to assign members who will evaluate projects in this round.
</p>
<Button size="sm" className="mt-4" onClick={() => setCreateJuryOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Create First Jury
</Button>
</div>
)}
</CardContent>
</Card>
{/* ── Members list (only if a jury group is assigned) ── */}
{juryGroupDetail && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
Members &mdash; {juryGroupDetail.name}
</CardTitle>
<CardDescription>
{juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{juryGroupDetail.members.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<UserPlus className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Members Yet</p>
<p className="text-xs text-muted-foreground mt-1">
Add jury members to start assigning projects for evaluation.
</p>
<Button size="sm" variant="outline" className="mt-4" onClick={() => setAddMemberOpen(true)}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add First Member
</Button>
</div>
) : (
<div className="space-y-1">
{juryGroupDetail.members.map((member: any, idx: number) => (
<div
key={member.id}
className={cn(
'flex items-center justify-between py-2.5 px-3 rounded-md transition-colors',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{member.user.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride as number | null}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => updateJuryMemberMutation.mutate({
id: member.id,
maxAssignmentsOverride: val,
})}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.user.name || member.user.email} from {juryGroupDetail.name}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeJuryMemberMutation.mutate({ id: member.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* ── Assignments content (only shown when jury group is assigned) ── */}
{round?.juryGroupId && (
<>
{/* Card 1: Coverage & Generation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Coverage & Generation</CardTitle>
<CardDescription>Assignment coverage overview and AI generation</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
{/* Generate Assignments */}
<div className={cn('rounded-lg border p-4 space-y-3', aiAssignmentMutation.isPending && 'border-violet-300 bg-violet-50/30')}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium flex items-center gap-2">
Assignment Generation
{aiAssignmentMutation.isPending && (
<Badge variant="outline" className="gap-1.5 text-violet-600 border-violet-300 animate-pulse">
<Loader2 className="h-3 w-3 animate-spin" />
AI generating...
</Badge>
)}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<Badge variant="outline" className="gap-1 text-emerald-600 border-emerald-300">
<CheckCircle2 className="h-3 w-3" />
{aiAssignmentMutation.data.stats.assignmentsGenerated} ready
</Badge>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
AI-suggested jury-to-project assignments based on expertise and workload
</p>
</div>
<div className="flex items-center gap-2">
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<Button
size="sm"
variant="outline"
onClick={() => setPreviewSheetOpen(true)}
>
Review Assignments
</Button>
)}
<Button
size="sm"
onClick={() => {
aiAssignmentMutation.mutate({
roundId,
requiredReviews: (config.requiredReviewsPerProject as number) || 3,
})
}}
disabled={projectCount === 0 || !juryGroup || aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
Generating...
</>
) : (
<>
<Zap className="h-4 w-4 mr-1.5" />
{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}
</>
)}
</Button>
</div>
</div>
{projectCount === 0 && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
<AlertTriangle className="h-4 w-4 shrink-0" />
Add projects to this round first.
</div>
)}
{juryGroup && projectCount > 0 && !aiAssignmentMutation.isPending && !aiAssignmentMutation.data && (
<p className="text-sm text-muted-foreground">
Click &quot;Generate with AI&quot; to create assignments using GPT analysis of juror expertise, project descriptions, and documents. Or open the preview to use the algorithm instead.
</p>
)}
{aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-violet-50 border border-violet-200 dark:bg-violet-950/20 dark:border-violet-800">
<div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-violet-300 border-t-violet-600 animate-spin" />
</div>
<div>
<p className="text-sm font-medium text-violet-800 dark:text-violet-200">AI is analyzing projects and jurors...</p>
<p className="text-xs text-violet-600 dark:text-violet-400">
Matching expertise, reviewing bios, and balancing workloads
</p>
</div>
</div>
)}
{aiAssignmentMutation.error && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-950/20 dark:border-red-800">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
AI generation failed
</p>
<p className="text-xs text-red-600 dark:text-red-400">
{aiAssignmentMutation.error.message}
</p>
</div>
</div>
)}
{aiAssignmentMutation.data && !aiAssignmentMutation.isPending && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 border border-emerald-200 dark:bg-emerald-950/20 dark:border-emerald-800">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
{aiAssignmentMutation.data.stats.assignmentsGenerated} assignments generated
</p>
<p className="text-xs text-emerald-600 dark:text-emerald-400">
{aiAssignmentMutation.data.stats.totalJurors} jurors, {aiAssignmentMutation.data.stats.totalProjects} projects
{aiAssignmentMutation.data.fallbackUsed && ' (algorithm fallback)'}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review &amp; Execute
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Jury Progress + Score Distribution (standalone 2-col grid) */}
<div className="grid gap-4 lg:grid-cols-2">
<JuryProgressTable roundId={roundId} />
<ScoreDistribution roundId={roundId} />
</div>
{/* Reassignment History (collapsible) */}
<ReassignmentHistory roundId={roundId} />
{/* Card 2: Assignments — with action buttons in header */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Assignments</CardTitle>
<CardDescription>Individual jury-project assignments and actions</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<SendRemindersButton roundId={roundId} />
<NotifyJurorsButton roundId={roundId} />
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<Download className="h-4 w-4 mr-1.5" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
</CardContent>
</Card>
{/* Card 3: Monitoring — COI + Unassigned Queue */}
<Card>
<CardHeader>
<CardTitle className="text-base">Monitoring</CardTitle>
<CardDescription>Conflict of interest declarations and unassigned projects</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<COIReviewSection roundId={roundId} />
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
</CardContent>
</Card>
{/* Assignment Preview Sheet */}
<AssignmentPreviewSheet
roundId={roundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
requiredReviews={(config.requiredReviewsPerProject as number) || 3}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({
roundId,
requiredReviews: (config.requiredReviewsPerProject as number) || 3,
})}
onResetAI={() => aiAssignmentMutation.reset()}
/>
{/* CSV Export Dialog */}
<ExportEvaluationsDialog roundId={roundId} open={exportOpen} onOpenChange={setExportOpen} />
</>
)}
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
<Card>
<CardHeader className="border-b">
<ConfigSectionHeader
title="Round Dates"
description="When this round starts and ends. Defines the active period for document uploads and evaluations."
status={round.windowOpenAt && round.windowCloseAt ? 'complete' : 'error'}
summary={
round.windowOpenAt && round.windowCloseAt
? `${new Date(round.windowOpenAt).toLocaleDateString()}${new Date(round.windowCloseAt).toLocaleDateString()}`
: undefined
}
/>
</CardHeader>
<CardContent className="pt-4">
{!round.windowOpenAt && !round.windowCloseAt && (
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Dates not set this round cannot be activated without dates.</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date }, { onSuccess: () => toast.success('Dates saved') })}
placeholder="Select start date & time"
clearable
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date }, { onSuccess: () => toast.success('Dates saved') })}
placeholder="Select end date & time"
clearable
/>
</div>
</div>
</CardContent>
</Card>
{/* General Round Settings */}
<Card>
<CardHeader className="border-b">
<ConfigSectionHeader
title="General Settings"
description="Settings that apply to this round regardless of type"
status={
isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number)
? 'warning'
: 'complete'
}
summary={
(config.startupAdvanceCount as number) || (config.conceptAdvanceCount as number)
? `Target: ${config.startupAdvanceCount ?? 0} startup, ${config.conceptAdvanceCount ?? 0} concept`
: undefined
}
/>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
</Label>
<p className="text-xs text-muted-foreground">
Send an automated email to project applicants when their project enters this round
</p>
</div>
<Switch
id="notify-on-entry"
checked={!!config.notifyOnEntry}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnEntry: checked })
}}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label>
{isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
<div className="mt-2 mb-1 rounded-md border border-amber-200 bg-amber-50 px-3 py-2">
<p className="text-xs text-amber-700">Advancement targets not configured all passed projects will be eligible to advance.</p>
</div>
)}
<p className="text-xs text-muted-foreground mb-3">
Target number of projects per category to advance from this round
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startup-advance-count" className="text-xs text-muted-foreground">
Startup Projects
</Label>
<Input
id="startup-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.startupAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, startupAdvanceCount: val })
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="concept-advance-count" className="text-xs text-muted-foreground">
Concept Projects
</Label>
<Input
id="concept-advance-count"
type="number"
min={0}
className="h-9"
placeholder="No limit"
value={(config.conceptAdvanceCount as number) ?? ''}
onChange={(e) => {
const val = e.target.value ? parseInt(e.target.value, 10) : undefined
handleConfigChange({ ...config, conceptAdvanceCount: val })
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Round-type-specific config */}
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
juryGroups={juryGroups?.map((jg: any) => ({ id: jg.id, name: jg.name }))}
/>
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} />}
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
<Card>
<CardHeader>
<ConfigSectionHeader
title="Document Requirements"
description={
`Files applicants must submit for this round` +
(round.windowCloseAt ? ` — due by ${new Date(round.windowCloseAt).toLocaleDateString()}` : '')
}
status={(fileRequirements?.length ?? 0) > 0 ? 'complete' : 'warning'}
summary={
(fileRequirements?.length ?? 0) > 0
? `${fileRequirements?.length} requirement(s)`
: undefined
}
/>
</CardHeader>
<CardContent>
<FileRequirementsEditor
roundId={roundId}
windowOpenAt={round.windowOpenAt}
windowCloseAt={round.windowCloseAt}
/>
</CardContent>
</Card>
)}
</TabsContent>
{/* ═══════════ AWARDS TAB ═══════════ */}
{hasAwards && (
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
{roundAwards.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<div className="rounded-full bg-[#de0f1e]/10 p-4 w-fit mx-auto mb-4">
<Trophy className="h-8 w-8 text-[#de0f1e]/60" />
</div>
<p className="text-sm font-medium text-foreground">No Awards Linked</p>
<p className="text-xs mt-1 max-w-sm mx-auto">
Create an award and set this round as its evaluation round to see it here
</p>
</div>
) : (
<div className="space-y-3">
{roundAwards.map((award) => {
const eligibleCount = award._count?.eligibilities || 0
return (
<Link
key={award.id}
href={`/admin/awards/${award.id}` as Route}
className="block"
>
<div className="flex items-start justify-between gap-4 rounded-lg border border-l-4 border-l-[#de0f1e] p-4 transition-all hover:shadow-md hover:-translate-y-0.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{award.name}</h3>
<Badge
variant={
award.eligibilityMode === 'SEPARATE_POOL'
? 'default'
: 'secondary'
}
className="shrink-0"
>
{award.eligibilityMode === 'SEPARATE_POOL'
? 'Separate Pool'
: 'Stay in Main'}
</Badge>
</div>
{award.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{award.description}
</p>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
<div className="text-right">
<div className="font-medium text-foreground">{eligibleCount}</div>
<div className="text-xs">eligible</div>
</div>
</div>
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
{/* Autosave error bar — only shows when save fails */}
{autosaveStatus === 'error' && (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-red-50 dark:bg-red-950/50 shadow-[0_-4px_12px_rgba(0,0,0,0.1)]">
<div className="container flex items-center justify-between py-3 px-4 max-w-5xl mx-auto">
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
<AlertTriangle className="h-4 w-4" />
<span>Auto-save failed</span>
</div>
<Button
size="sm"
onClick={saveConfig}
disabled={updateMutation.isPending}
className="bg-[#de0f1e] hover:bg-[#c00d1a] text-white"
>
{updateMutation.isPending ? (
<><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Retrying...</>
) : (
<><Save className="h-3.5 w-3.5 mr-1.5" />Retry Save</>
)}
</Button>
</div>
</div>
)}
</div>
)
}