MOPC-App/src/app/(admin)/admin/rounds/[roundId]/page.tsx

1747 lines
71 KiB
TypeScript

'use client'
import { useState, useMemo, useCallback } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ArrowLeft,
Save,
Loader2,
ChevronDown,
Play,
Square,
Archive,
Layers,
Users,
CalendarDays,
BarChart3,
ClipboardList,
Settings,
Zap,
Shield,
UserPlus,
CheckCircle2,
AlertTriangle,
FileText,
Trophy,
Clock,
Send,
Download,
Plus,
Trash2,
ArrowRight,
X,
} from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
// ── Status & type config maps ──────────────────────────────────────────────
const roundStatusConfig = {
ROUND_DRAFT: {
label: 'Draft',
bgClass: 'bg-gray-100 text-gray-700',
dotClass: 'bg-gray-500',
description: 'Not yet active. Configure before launching.',
},
ROUND_ACTIVE: {
label: 'Active',
bgClass: 'bg-emerald-100 text-emerald-700',
dotClass: 'bg-emerald-500 animate-pulse',
description: 'Round is live. Projects can be processed.',
},
ROUND_CLOSED: {
label: 'Closed',
bgClass: 'bg-blue-100 text-blue-700',
dotClass: 'bg-blue-500',
description: 'No longer accepting changes. Results are final.',
},
ROUND_ARCHIVED: {
label: 'Archived',
bgClass: 'bg-muted text-muted-foreground',
dotClass: 'bg-muted-foreground',
description: 'Historical record only.',
},
} as const
const roundTypeConfig: Record<string, { label: string; color: string; description: string }> = {
INTAKE: { label: 'Intake', color: 'bg-gray-100 text-gray-700', description: 'Collecting applications' },
FILTERING: { label: 'Filtering', color: 'bg-amber-100 text-amber-700', description: 'AI + manual screening' },
EVALUATION: { label: 'Evaluation', color: 'bg-blue-100 text-blue-700', description: 'Jury evaluation & scoring' },
SUBMISSION: { label: 'Submission', color: 'bg-purple-100 text-purple-700', description: 'Document submission' },
MENTORING: { label: 'Mentoring', color: 'bg-teal-100 text-teal-700', description: 'Mentor-guided development' },
LIVE_FINAL: { label: 'Live Final', color: 'bg-red-100 text-red-700', description: 'Live presentations & voting' },
DELIBERATION: { label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700', description: 'Final jury deliberation' },
}
const stateColors: Record<string, string> = {
PENDING: 'bg-gray-400',
IN_PROGRESS: 'bg-blue-500',
PASSED: 'bg-green-500',
REJECTED: 'bg-red-500',
COMPLETED: 'bg-emerald-500',
WITHDRAWN: 'bg-orange-400',
}
// ═══════════════════════════════════════════════════════════════════════════
// Main Page Component
// ═══════════════════════════════════════════════════════════════════════════
export default function RoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const utils = trpc.useUtils()
// ── Core data queries ──────────────────────────────────────────────────
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery({ roundId })
const competitionId = round?.competitionId ?? ''
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
{ competitionId },
{ enabled: !!competitionId },
)
const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId })
// Fetch awards linked to this round
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: !!competitionId },
)
const programId = competition?.programId
const { data: awards } = trpc.specialAward.list.useQuery(
{ programId: programId! },
{ enabled: !!programId },
)
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
// Sync config from server when not dirty
if (round && !hasChanges) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
}
}
// ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
},
onError: (err) => toast.error(err.message),
})
const activateMutation = trpc.roundEngine.activate.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round activated')
},
onError: (err) => toast.error(err.message),
})
const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
},
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 })
toast.success('Jury group 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 })
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}, [])
const handleSave = useCallback(() => {
updateMutation.mutate({ id: roundId, configJson: config })
}, [roundId, config, updateMutation])
// ── Computed values ────────────────────────────────────────────────────
const projectCount = round?._count?.projectRoundStates ?? 0
const stateCounts = useMemo(() =>
projectStates?.reduce((acc: Record<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 = juryGroup?.members?.length ?? 0
const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION'
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
// ── Loading state ──────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div className="space-y-2">
<Skeleton className="h-7 w-64" />
<Skeleton className="h-4 w-40" />
</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-24" />)}
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</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',
},
...((isEvaluation || isFiltering)
? [{
label: 'Jury group set',
ready: !!juryGroup,
detail: juryGroup ? `${juryGroup.name} (${juryMemberCount} members)` : 'No jury group assigned',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
}]
: []),
{
label: 'Dates configured',
ready: !!round.windowOpenAt && !!round.windowCloseAt,
detail:
round.windowOpenAt && round.windowCloseAt
? `${new Date(round.windowOpenAt).toLocaleDateString()} \u2014 ${new Date(round.windowCloseAt).toLocaleDateString()}`
: 'No dates set \u2014 configure in Config tab',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
},
{
label: 'File requirements set',
ready: (fileRequirements?.length ?? 0) > 0,
detail:
(fileRequirements?.length ?? 0) > 0
? `${fileRequirements?.length} requirement(s)`
: 'No file requirements \u2014 configure in Config tab',
action: undefined as Route | undefined,
actionLabel: undefined as string | undefined,
},
]
const readyCount = readinessItems.filter((i) => i.ready).length
// ═════════════════════════════════════════════════════════════════════════
// Render
// ═════════════════════════════════════════════════════════════════════════
return (
<div className="space-y-6">
{/* ===== HEADER ===== */}
<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={'/admin/rounds' as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to rounds">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold tracking-tight truncate">{round.name}</h1>
<Badge variant="secondary" className={cn('text-xs shrink-0', typeCfg.color)}>
{typeCfg.label}
</Badge>
{/* Status dropdown */}
<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',
statusCfg.bgClass,
'hover:opacity-80',
)}
>
<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' && (
<DropdownMenuItem
onClick={() => activateMutation.mutate({ roundId })}
disabled={isTransitioning}
>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Activate Round
</DropdownMenuItem>
)}
{status === 'ROUND_ACTIVE' && (
<DropdownMenuItem
onClick={() => closeMutation.mutate({ roundId })}
disabled={isTransitioning}
>
<Square className="h-4 w-4 mr-2 text-blue-600" />
Close Round
</DropdownMenuItem>
)}
{status === 'ROUND_CLOSED' && (
<>
<DropdownMenuItem
onClick={() => activateMutation.mutate({ roundId })}
disabled={isTransitioning}
>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Reactivate Round
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => archiveMutation.mutate({ roundId })}
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>
</div>
<p className="text-sm text-muted-foreground mt-0.5">{typeCfg.description}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{hasChanges && (
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1.5" />
)}
Save Config
</Button>
)}
<Link href={poolLink}>
<Button variant="outline" size="sm">
<Layers className="h-4 w-4 mr-1.5" />
Project Pool
</Button>
</Link>
</div>
</div>
{/* ===== STATS BAR ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{/* Projects */}
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Projects</span>
</div>
</div>
<p className="text-2xl font-bold mt-1">{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>
{/* Jury (with inline group selector) */}
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2 mb-1" data-jury-select>
<Users className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Jury</span>
</div>
{juryGroups && juryGroups.length > 0 ? (
<Select
value={round.juryGroupId ?? '__none__'}
onValueChange={(value) => {
assignJuryMutation.mutate({
id: roundId,
juryGroupId: value === '__none__' ? null : value,
})
}}
disabled={assignJuryMutation.isPending}
>
<SelectTrigger className="h-8 text-xs mt-1">
<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>
) : juryGroup ? (
<>
<p className="text-2xl font-bold mt-1">{juryMemberCount}</p>
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No jury groups yet</p>
</>
)}
</CardContent>
</Card>
{/* Window */}
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Window</span>
</div>
{round.windowOpenAt || round.windowCloseAt ? (
<>
<p className="text-sm font-bold mt-1">
{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>
</>
) : (
<>
<p className="text-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">No dates set</p>
</>
)}
</CardContent>
</Card>
{/* Advancement */}
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Advancement</span>
</div>
{round.advancementRules && round.advancementRules.length > 0 ? (
<>
<p className="text-2xl font-bold mt-1">{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-2xl font-bold mt-1 text-muted-foreground">&mdash;</p>
<p className="text-xs text-muted-foreground">Admin selection</p>
</>
)}
</CardContent>
</Card>
</div>
{/* ===== TABS ===== */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="overview">
<Zap className="h-3.5 w-3.5 mr-1.5" />
Overview
</TabsTrigger>
<TabsTrigger value="projects">
<Layers className="h-3.5 w-3.5 mr-1.5" />
Projects
</TabsTrigger>
{isFiltering && (
<TabsTrigger value="filtering">
<Shield className="h-3.5 w-3.5 mr-1.5" />
Filtering
</TabsTrigger>
)}
{isEvaluation && (
<TabsTrigger value="assignments">
<ClipboardList className="h-3.5 w-3.5 mr-1.5" />
Assignments
</TabsTrigger>
)}
<TabsTrigger value="config">
<Settings className="h-3.5 w-3.5 mr-1.5" />
Config
</TabsTrigger>
<TabsTrigger value="windows">
<Clock className="h-3.5 w-3.5 mr-1.5" />
Submissions
</TabsTrigger>
<TabsTrigger value="awards">
<Trophy className="h-3.5 w-3.5 mr-1.5" />
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>
{/* ═══════════ OVERVIEW TAB ═══════════ */}
<TabsContent value="overview" className="space-y-6">
{/* Readiness Checklist */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Readiness Checklist</CardTitle>
<CardDescription>
{readyCount}/{readinessItems.length} items ready
</CardDescription>
</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-muted-foreground')}>
{item.label}
</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
{item.action && (
<Link href={item.action}>
<Button variant="outline" size="sm" className="shrink-0 text-xs">
{item.actionLabel}
</Button>
</Link>
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>
<CardDescription>Common operations for this round</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* Status transitions */}
{status === 'ROUND_DRAFT' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors 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 hover:bg-muted/50 transition-colors 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>
)}
{/* Assign projects */}
<Link href={poolLink}>
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
<Layers className="h-5 w-5 text-blue-600 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>
{/* Filtering specific */}
{isFiltering && (
<button
onClick={() => setActiveTab('filtering')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
>
<Shield className="h-5 w-5 text-amber-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>
)}
{/* Jury assignment for evaluation/filtering */}
{(isEvaluation || isFiltering) && !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 hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50"
>
<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:bg-muted/50 transition-colors text-left"
>
<ClipboardList className="h-5 w-5 text-blue-600 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>
)}
{/* View projects */}
<button
onClick={() => setActiveTab('projects')}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
>
<BarChart3 className="h-5 w-5 text-purple-600 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 (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-green-200 bg-green-50/50"
>
<ArrowRight className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
</div>
</CardContent>
</Card>
{/* Advance Projects Confirmation Dialog */}
<AlertDialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Advance {passedCount} project(s)?</AlertDialogTitle>
<AlertDialogDescription>
All projects with PASSED status in this round will be moved to the next round.
This action creates new entries in the next round and marks current entries as completed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => advanceMutation.mutate({ roundId })}
disabled={advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance Projects
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Round Info + Project Breakdown */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-sm">Round Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type</span>
<Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className="font-medium">{statusCfg.label}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sort Order</span>
<span className="font-medium font-mono">{round.sortOrder}</span>
</div>
{round.purposeKey && (
<div className="flex justify-between">
<span className="text-muted-foreground">Purpose</span>
<span className="font-medium">{round.purposeKey}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Jury Group</span>
<span className="font-medium">
{juryGroup ? juryGroup.name : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opens</span>
<span className="font-medium">
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Closes</span>
<span className="font-medium">
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Project Breakdown</CardTitle>
</CardHeader>
<CardContent>
{projectCount === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No projects assigned yet
</p>
) : (
<div className="space-y-2">
{['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">
<span className="text-muted-foreground capitalize">{state.toLowerCase().replace('_', ' ')}</span>
<span className="font-medium">{count} ({pct}%)</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', stateColors[state])}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</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>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
<TabsContent value="assignments" className="space-y-6">
{/* Coverage Report */}
<CoverageReport roundId={roundId} />
{/* Generate Assignments */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Assignment Generation</CardTitle>
<CardDescription>
AI-suggested jury-to-project assignments based on expertise and workload
</CardDescription>
</div>
<Button
size="sm"
onClick={() => setPreviewSheetOpen(true)}
disabled={projectCount === 0 || !juryGroup}
>
<Zap className="h-4 w-4 mr-1.5" />
Generate Assignments
</Button>
</div>
</CardHeader>
<CardContent>
{!juryGroup && (
<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" />
Assign a jury group first before generating assignments.
</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 && (
<p className="text-sm text-muted-foreground">
Click &quot;Generate Assignments&quot; to preview AI-suggested assignments.
You can review and execute them from the preview sheet.
</p>
)}
</CardContent>
</Card>
{/* Jury Progress + Score Distribution */}
<div className="grid gap-4 lg:grid-cols-2">
<JuryProgressTable roundId={roundId} />
<ScoreDistribution roundId={roundId} />
</div>
{/* Actions: Send Reminders + Export */}
<div className="flex flex-wrap items-center gap-3">
<SendRemindersButton roundId={roundId} />
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
<Download className="h-4 w-4 mr-1.5" />
Export Evaluations
</Button>
</div>
{/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
{/* Assignment Preview Sheet */}
<AssignmentPreviewSheet
roundId={roundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
/>
{/* CSV Export Dialog */}
<ExportEvaluationsDialog roundId={roundId} open={exportOpen} onOpenChange={setExportOpen} />
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* General Round Settings */}
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
<CardDescription>Settings that apply to this round regardless of type</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<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>
</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 */}
<Card>
<CardHeader>
<CardTitle className="text-base">Document Requirements</CardTitle>
<CardDescription>
Files applicants must submit for this round
{round.windowCloseAt && (
<> &mdash; due by {new Date(round.windowCloseAt).toLocaleDateString()}</>
)}
</CardDescription>
</CardHeader>
<CardContent>
<FileRequirementsEditor
roundId={roundId}
windowOpenAt={round.windowOpenAt}
windowCloseAt={round.windowCloseAt}
/>
</CardContent>
</Card>
</TabsContent>
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* ═══════════ AWARDS TAB ═══════════ */}
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
{roundAwards.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No awards linked to this round</p>
<p className="text-xs mt-1">
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
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
const ruleCount = autoTagRules?.rules?.length || 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 p-4 transition-all hover:bg-muted/50 hover:shadow-sm">
<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">{ruleCount}</div>
<div className="text-xs">{ruleCount === 1 ? 'rule' : 'rules'}</div>
</div>
<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>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// Sub-components
// ═══════════════════════════════════════════════════════════════════════════
// ── Unassigned projects queue ────────────────────────────────────────────
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId, requiredReviews: 3 },
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Unassigned Projects</CardTitle>
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : unassigned && unassigned.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{unassigned.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground">
{project.competitionCategory || 'No category'}
{project.teamName && ` \u00b7 ${project.teamName}`}
</p>
</div>
<Badge variant="outline" className={cn(
'text-xs shrink-0 ml-3',
(project.assignmentCount || 0) === 0
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-amber-50 text-amber-700 border-amber-200',
)}>
{project.assignmentCount || 0} / 3
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
)
}
// ── Jury Progress Table ──────────────────────────────────────────────────
function JuryProgressTable({ roundId }: { roundId: string }) {
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery({ roundId })
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Progress</CardTitle>
<CardDescription>Evaluation completion per juror</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !workload || workload.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No assignments yet
</p>
) : (
<div className="space-y-3 max-h-[350px] overflow-y-auto">
{workload.map((juror) => {
const pct = juror.completionRate
const barColor = pct === 100
? 'bg-emerald-500'
: pct >= 50
? 'bg-blue-500'
: pct > 0
? 'bg-amber-500'
: 'bg-gray-300'
return (
<div key={juror.id} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="font-medium truncate max-w-[60%]">{juror.name}</span>
<span className="text-muted-foreground shrink-0">
{juror.completed}/{juror.assigned} ({pct}%)
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', barColor)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
// ── Score Distribution ───────────────────────────────────────────────────
function ScoreDistribution({ roundId }: { roundId: string }) {
const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery({ roundId })
const maxCount = useMemo(() =>
dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
[dist])
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Score Distribution</CardTitle>
<CardDescription>
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-end gap-1 h-32">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
</div>
) : !dist || dist.totalEvaluations === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No evaluations submitted yet
</p>
) : (
<div className="flex items-end gap-1 h-32">
{dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
<div className="w-full relative rounded-t" style={{ height: `${Math.max(heightPct, 2)}%` }}>
<div className={cn(
'absolute inset-0 rounded-t transition-all',
bucket.score <= 3 ? 'bg-red-400' :
bucket.score <= 6 ? 'bg-amber-400' :
'bg-emerald-400',
)} />
</div>
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
// ── Send Reminders Button ────────────────────────────────────────────────
function SendRemindersButton({ roundId }: { roundId: string }) {
const [open, setOpen] = useState(false)
const mutation = trpc.evaluation.triggerReminders.useMutation({
onSuccess: (data) => {
toast.success(`Sent ${data.sent} reminder(s)`)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Send className="h-4 w-4 mr-1.5" />
Send Reminders
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send evaluation reminders?</AlertDialogTitle>
<AlertDialogDescription>
This will send reminder emails to all jurors who have incomplete evaluations for this round.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => mutation.mutate({ roundId })}
disabled={mutation.isPending}
>
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Send Reminders
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// ── Export Evaluations Dialog ─────────────────────────────────────────────
function ExportEvaluationsDialog({
roundId,
open,
onOpenChange,
}: {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [exportData, setExportData] = useState<any>(undefined)
const [isLoadingExport, setIsLoadingExport] = useState(false)
const utils = trpc.useUtils()
const handleRequestData = async () => {
setIsLoadingExport(true)
try {
const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true })
setExportData(data)
return data
} finally {
setIsLoadingExport(false)
}
}
return (
<CsvExportDialog
open={open}
onOpenChange={onOpenChange}
exportData={exportData}
isLoading={isLoadingExport}
filename={`evaluations-${roundId}`}
onRequestData={handleRequestData}
/>
)
}
// ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [newUserId, setNewUserId] = useState('')
const [newProjectId, setNewProjectId] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery({ roundId })
const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
toast.success('Assignment removed')
},
onError: (err) => toast.error(err.message),
})
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
toast.success('Assignment created')
setAddDialogOpen(false)
setNewUserId('')
setNewProjectId('')
},
onError: (err) => toast.error(err.message),
})
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">All Assignments</CardTitle>
<CardDescription>
{assignments?.length ?? 0} individual jury-project assignments
</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Add
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : !assignments || assignments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No assignments yet. Generate assignments or add one manually.
</p>
) : (
<div className="space-y-1 max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-[1fr_1fr_100px_60px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
<span>Juror</span>
<span>Project</span>
<span>Status</span>
<span />
</div>
{assignments.map((a: any) => (
<div
key={a.id}
className="grid grid-cols-[1fr_1fr_100px_60px] gap-2 items-center px-3 py-2 rounded-md hover:bg-muted/30 text-sm"
>
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
a.evaluation?.status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a.evaluation?.status === 'IN_PROGRESS'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
)}
>
{a.evaluation?.status || 'PENDING'}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteMutation.mutate({ id: a.id })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-red-500" />
</Button>
</div>
))}
</div>
)}
</CardContent>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
Manually assign a juror to evaluate a project
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Juror User ID</Label>
<Input
placeholder="Enter jury member user ID..."
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Project ID</Label>
<Input
placeholder="Enter project ID..."
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => createMutation.mutate({
userId: newUserId,
projectId: newProjectId,
roundId,
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create Assignment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
// ── Evaluation Criteria Editor ───────────────────────────────────────────
function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
const [editing, setEditing] = useState(false)
const [criteria, setCriteria] = useState<Array<{
id: string; label: string; description?: string; weight?: number; minScore?: number; maxScore?: number
}>>([])
const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery({ roundId })
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId })
toast.success('Evaluation criteria saved')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
// Sync from server
if (form && !editing) {
const serverCriteria = form.criteriaJson ?? []
if (JSON.stringify(serverCriteria) !== JSON.stringify(criteria)) {
setCriteria(serverCriteria)
}
}
const handleAdd = () => {
setCriteria([...criteria, {
id: `c-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
label: '',
description: '',
weight: 1,
minScore: 0,
maxScore: 10,
}])
setEditing(true)
}
const handleRemove = (id: string) => {
setCriteria(criteria.filter((c) => c.id !== id))
}
const handleChange = (id: string, field: string, value: string | number) => {
setCriteria(criteria.map((c) =>
c.id === id ? { ...c, [field]: value } : c,
))
setEditing(true)
}
const handleSave = () => {
const validCriteria = criteria.filter((c) => c.label.trim())
if (validCriteria.length === 0) {
toast.error('Add at least one criterion')
return
}
upsertMutation.mutate({ roundId, criteria: validCriteria })
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
<CardDescription>
{form ? `Version ${form.version} \u2014 ${form.criteriaJson.length} criteria` : 'No criteria defined yet'}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{editing && (
<Button size="sm" variant="outline" onClick={() => {
setEditing(false)
if (form) setCriteria(form.criteriaJson)
}}>
Cancel
</Button>
)}
{editing ? (
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Save Criteria
</Button>
) : (
<Button size="sm" variant="outline" onClick={handleAdd}>
<Plus className="h-4 w-4 mr-1.5" />
Add Criterion
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : criteria.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No evaluation criteria defined</p>
<p className="text-xs mt-1">Add criteria that jurors will use to score projects</p>
</div>
) : (
<div className="space-y-3">
{criteria.map((c, idx) => (
<div key={c.id} className="flex gap-3 items-start p-3 border rounded-lg bg-muted/20">
<span className="text-xs text-muted-foreground font-mono mt-2.5 shrink-0 w-5 text-center">
{idx + 1}
</span>
<div className="flex-1 space-y-2">
<Input
placeholder="Criterion label (e.g., Innovation)"
value={c.label}
onChange={(e) => handleChange(c.id, 'label', e.target.value)}
className="h-8 text-sm"
/>
<Input
placeholder="Description (optional)"
value={c.description || ''}
onChange={(e) => handleChange(c.id, 'description', e.target.value)}
className="h-7 text-xs"
/>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Weight</Label>
<Input
type="number"
min={0}
max={100}
value={c.weight ?? 1}
onChange={(e) => handleChange(c.id, 'weight', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Min Score</Label>
<Input
type="number"
min={0}
value={c.minScore ?? 0}
onChange={(e) => handleChange(c.id, 'minScore', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground">Max Score</Label>
<Input
type="number"
min={1}
value={c.maxScore ?? 10}
onChange={(e) => handleChange(c.id, 'maxScore', Number(e.target.value))}
className="h-7 text-xs"
/>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 mt-1 shrink-0"
onClick={() => { handleRemove(c.id); setEditing(true) }}
>
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-red-500" />
</Button>
</div>
))}
{!editing && (
<Button variant="outline" size="sm" className="w-full" onClick={handleAdd}>
<Plus className="h-4 w-4 mr-1.5" />
Add Criterion
</Button>
)}
</div>
)}
</CardContent>
</Card>
)
}