From 80c9e3597115d0f787cffc455477fd4235a53dfb Mon Sep 17 00:00:00 2001
From: Matt
Date: Mon, 16 Feb 2026 10:09:52 +0100
Subject: [PATCH] AI category-aware evaluation: per-round config, file parsing,
shortlist, advance flow
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router
Co-Authored-By: Claude Opus 4.6
---
package-lock.json | 228 ++++
package.json | 2 +
.../competitions/[competitionId]/page.tsx | 5 +-
.../[competitionId]/rounds/[roundId]/page.tsx | 1032 -----------------
src/app/(admin)/admin/dashboard-content.tsx | 10 +-
.../admin/projects/bulk-upload/page.tsx | 61 +-
.../(admin)/admin/projects/import/page.tsx | 2 +-
.../(admin)/admin/rounds/[roundId]/page.tsx | 474 +++++++-
src/app/(admin)/admin/rounds/page.tsx | 8 -
.../competition/competition-timeline.tsx | 7 +-
.../admin/jury/add-member-dialog.tsx | 357 ++++--
.../admin/jury/jury-members-table.tsx | 13 +-
src/server/routers/assignment.ts | 2 +-
src/server/routers/file.ts | 281 +++++
src/server/routers/filtering.ts | 79 +-
src/server/routers/round.ts | 69 ++
src/server/services/ai-filtering.ts | 14 +-
src/server/services/ai-shortlist.ts | 502 ++++----
src/server/services/anonymization.ts | 8 +-
src/server/services/file-content-extractor.ts | 112 ++
src/server/utils/ai-usage.ts | 1 +
21 files changed, 1886 insertions(+), 1381 deletions(-)
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx
create mode 100644 src/server/services/file-content-extractor.ts
diff --git a/package-lock.json b/package-lock.json
index e7921d5..a5c441b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,6 +62,7 @@
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
+ "pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@@ -83,6 +84,7 @@
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
+ "@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",
@@ -1629,6 +1631,190 @@
"react": ">=16.8.0"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
+ "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-arm64": "0.1.80",
+ "@napi-rs/canvas-darwin-x64": "0.1.80",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.80",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.80",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.80",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.80"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
+ "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
+ "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
+ "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
+ "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
+ "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
+ "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
+ "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
+ "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
+ "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.80",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
+ "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -4589,6 +4775,16 @@
"@types/node": "*"
}
},
+ "node_modules/@types/pdf-parse": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
+ "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -10826,6 +11022,38 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/pdf-parse": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
+ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@napi-rs/canvas": "0.1.80",
+ "pdfjs-dist": "5.4.296"
+ },
+ "bin": {
+ "pdf-parse": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": ">=20.16.0 <21 || >=22.3.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/mehmet-kozan"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
diff --git a/package.json b/package.json
index 9a47686..3c5eb6e 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
+ "pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@@ -96,6 +97,7 @@
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
+ "@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
index f6219c3..e8a0c48 100644
--- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
+++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
@@ -366,7 +366,7 @@ export default function CompetitionDetailPage() {
return (
@@ -510,9 +510,6 @@ export default function CompetitionDetailPage() {
- {competition.notifyOnRoundAdvance && (
-
Round Advance
- )}
{competition.notifyOnDeadlineApproach && (
Deadline Approach
)}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx
deleted file mode 100644
index 94ea1ae..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/rounds/[roundId]/page.tsx
+++ /dev/null
@@ -1,1032 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useParams, useRouter } 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 {
- 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 {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import {
- ArrowLeft,
- Save,
- Loader2,
- ChevronDown,
- Play,
- Square,
- Archive,
- Layers,
- Users,
- CalendarDays,
- BarChart3,
- ClipboardList,
- Settings,
- Zap,
- ExternalLink,
- Shield,
- UserPlus,
- CheckCircle2,
- AlertTriangle,
- CircleDot,
- FileText,
-} from 'lucide-react'
-import { Switch } from '@/components/ui/switch'
-import { Label } from '@/components/ui/label'
-import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
-import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
-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'
-
-// -- Status config --
-const roundStatusConfig = {
- ROUND_DRAFT: {
- label: 'Draft',
- bgClass: 'bg-gray-100 text-gray-700',
- dotClass: 'bg-gray-500',
- description: 'Not yet active. Configure before launching.',
- },
- ROUND_ACTIVE: {
- label: 'Active',
- bgClass: 'bg-emerald-100 text-emerald-700',
- dotClass: 'bg-emerald-500 animate-pulse',
- description: 'Round is live. Projects can be processed.',
- },
- ROUND_CLOSED: {
- label: 'Closed',
- bgClass: 'bg-blue-100 text-blue-700',
- dotClass: 'bg-blue-500',
- description: 'No longer accepting changes. Results are final.',
- },
- ROUND_ARCHIVED: {
- label: 'Archived',
- bgClass: 'bg-muted text-muted-foreground',
- dotClass: 'bg-muted-foreground',
- description: 'Historical record only.',
- },
-} as const
-
-const roundTypeConfig: Record
= {
- INTAKE: { label: 'Intake', color: 'bg-gray-100 text-gray-700', description: 'Collecting applications' },
- FILTERING: { label: 'Filtering', color: 'bg-amber-100 text-amber-700', description: 'AI + manual screening' },
- EVALUATION: { label: 'Evaluation', color: 'bg-blue-100 text-blue-700', description: 'Jury evaluation & scoring' },
- SUBMISSION: { label: 'Submission', color: 'bg-purple-100 text-purple-700', description: 'Document submission' },
- MENTORING: { label: 'Mentoring', color: 'bg-teal-100 text-teal-700', description: 'Mentor-guided development' },
- LIVE_FINAL: { label: 'Live Final', color: 'bg-red-100 text-red-700', description: 'Live presentations & voting' },
- DELIBERATION: { label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700', description: 'Final jury deliberation' },
-}
-
-export default function RoundDetailPage() {
- const params = useParams()
- const router = useRouter()
- const competitionId = params.competitionId as string
- const roundId = params.roundId as string
-
- const [config, setConfig] = useState>({})
- const [hasChanges, setHasChanges] = useState(false)
- const [activeTab, setActiveTab] = useState('overview')
- const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
-
- const utils = trpc.useUtils()
-
- const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
- const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery({ roundId })
- const { data: juryGroups } = trpc.juryGroup.list.useQuery(
- { competitionId },
- { enabled: !!competitionId },
- )
- const { data: fileRequirements } = trpc.file.listRequirements.useQuery({ roundId })
-
- // Sync config from server when not dirty
- if (round && !hasChanges) {
- const roundConfig = (round.configJson as Record) ?? {}
- if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
- setConfig(roundConfig)
- }
- }
-
- // -- Mutations --
- const updateMutation = trpc.round.update.useMutation({
- onSuccess: () => {
- utils.round.getById.invalidate({ id: roundId })
- toast.success('Round configuration saved')
- setHasChanges(false)
- },
- onError: (err) => toast.error(err.message),
- })
-
- const activateMutation = trpc.roundEngine.activate.useMutation({
- onSuccess: () => {
- utils.round.getById.invalidate({ id: roundId })
- toast.success('Round activated')
- },
- onError: (err) => toast.error(err.message),
- })
-
- const closeMutation = trpc.roundEngine.close.useMutation({
- onSuccess: () => {
- utils.round.getById.invalidate({ id: roundId })
- toast.success('Round closed')
- },
- 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 isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
-
- const handleConfigChange = (newConfig: Record) => {
- setConfig(newConfig)
- setHasChanges(true)
- }
-
- const handleSave = () => {
- updateMutation.mutate({ id: roundId, configJson: config })
- }
-
- // -- Computed --
- const projectCount = round?._count?.projectRoundStates ?? 0
- const stateCounts = projectStates?.reduce((acc: Record, ps: any) => {
- acc[ps.state] = (acc[ps.state] || 0) + 1
- return acc
- }, {} as Record) ?? {}
- const juryGroup = round?.juryGroup
- const juryMemberCount = juryGroup?.members?.length ?? 0
-
- // Round type flags
- const isFiltering = round?.roundType === 'FILTERING'
- const isEvaluation = round?.roundType === 'EVALUATION'
-
- // Pool link with context params
- const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
-
- // Loading
- if (isLoading) {
- return (
-
-
-
- {[1, 2, 3, 4].map((i) => )}
-
-
-
-
- )
- }
-
- if (!round) {
- return (
-
-
-
-
-
-
-
Round Not Found
-
This round does not exist.
-
-
-
- )
- }
-
- const status = round.status as keyof typeof roundStatusConfig
- const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
- const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
-
- // -- Readiness checklist items --
- 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
-
- return (
-
- {/* ===== HEADER ===== */}
-
-
-
-
-
-
-
-
{round.name}
-
- {typeCfg.label}
-
-
- {/* Status dropdown */}
-
-
-
-
-
- {status === 'ROUND_DRAFT' && (
- activateMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Activate Round
-
- )}
- {status === 'ROUND_ACTIVE' && (
- closeMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Close Round
-
- )}
- {status === 'ROUND_CLOSED' && (
- <>
- activateMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Reactivate Round
-
-
- archiveMutation.mutate({ roundId })}
- disabled={isTransitioning}
- >
-
- Archive Round
-
- >
- )}
- {isTransitioning && (
-
-
- Updating...
-
- )}
-
-
-
-
{typeCfg.description}
-
-
-
- {/* Action buttons */}
-
- {hasChanges && (
-
- )}
-
-
-
-
-
-
- {/* ===== STATS BAR ===== */}
-
-
-
-
- {projectCount}
-
- {Object.entries(stateCounts).map(([state, count]) => (
-
- {String(count)} {state.toLowerCase().replace('_', ' ')}
-
- ))}
-
-
-
-
-
-
-
-
- Jury
-
- {juryGroups && juryGroups.length > 0 ? (
-
- ) : juryGroup ? (
- <>
- {juryMemberCount}
- {juryGroup.name}
- >
- ) : (
- <>
- —
- No jury groups yet
- >
- )}
-
-
-
-
-
-
-
- Window
-
- {round.windowOpenAt || round.windowCloseAt ? (
- <>
-
- {round.windowOpenAt
- ? new Date(round.windowOpenAt).toLocaleDateString()
- : 'No start'}
-
-
- {round.windowCloseAt
- ? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
- : 'No deadline'}
-
- >
- ) : (
- <>
- —
- No dates set
- >
- )}
-
-
-
-
-
-
-
- Advancement
-
- {round.advancementRules && round.advancementRules.length > 0 ? (
- <>
- {round.advancementRules.length}
-
- {round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
-
- >
- ) : (
- <>
- —
- Admin selection
- >
- )}
-
-
-
-
- {/* ===== TABS ===== */}
-
-
-
-
- Overview
-
-
-
- Projects
-
- {isFiltering && (
-
-
- Filtering
-
- )}
- {isEvaluation && (
-
-
- Assignments
-
- )}
-
-
- Config
-
-
-
- {/* ===== OVERVIEW TAB ===== */}
-
- {/* Readiness Checklist */}
-
-
-
-
- Readiness Checklist
-
- {readyCount}/{readinessItems.length} items ready
-
-
-
- {readyCount === readinessItems.length ? 'Ready' : 'Incomplete'}
-
-
-
-
-
- {readinessItems.map((item) => (
-
- {item.ready ? (
-
- ) : (
-
- )}
-
-
- {item.label}
-
-
{item.detail}
-
- {item.action && (
-
-
-
- )}
-
- ))}
-
-
-
-
- {/* Quick Actions */}
-
-
- Quick Actions
- Common operations for this round
-
-
-
- {/* Status transitions */}
- {status === 'ROUND_DRAFT' && (
-
-
-
-
-
-
- Activate this round?
-
- The round will go live. Projects can be processed and jury members will be able to see their assignments.
-
-
-
- Cancel
- activateMutation.mutate({ roundId })}>
- Activate
-
-
-
-
- )}
-
- {status === 'ROUND_ACTIVE' && (
-
-
-
-
-
-
- Close this round?
-
- No further changes will be accepted. You can reactivate later if needed.
- {projectCount > 0 && (
-
- {projectCount} projects are currently in this round.
-
- )}
-
-
-
- Cancel
- closeMutation.mutate({ roundId })}>
- Close Round
-
-
-
-
- )}
-
- {/* Assign projects */}
-
-
-
-
- {/* Filtering specific */}
- {isFiltering && (
-
- )}
-
- {/* Jury assignment for evaluation/filtering */}
- {(isEvaluation || isFiltering) && !juryGroup && (
-
- )}
-
- {/* Evaluation: generate assignments */}
- {isEvaluation && (
-
- )}
-
- {/* View projects */}
-
-
-
-
-
- {/* Round info */}
-
-
-
- Round Details
-
-
-
- Type
- {typeCfg.label}
-
-
- Status
- {statusCfg.label}
-
-
- Sort Order
- {round.sortOrder}
-
- {round.purposeKey && (
-
- Purpose
- {round.purposeKey}
-
- )}
-
- Jury Group
-
- {juryGroup ? juryGroup.name : '\u2014'}
-
-
-
- Opens
-
- {round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '\u2014'}
-
-
-
- Closes
-
- {round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '\u2014'}
-
-
-
-
-
-
-
- Project Breakdown
-
-
- {projectCount === 0 ? (
-
- No projects assigned yet
-
- ) : (
-
- {['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => {
- const count = stateCounts[state] || 0
- if (count === 0) return null
- const pct = ((count / projectCount) * 100).toFixed(0)
- const colors: Record
= {
- PENDING: 'bg-gray-400',
- IN_PROGRESS: 'bg-blue-500',
- PASSED: 'bg-green-500',
- REJECTED: 'bg-red-500',
- COMPLETED: 'bg-emerald-500',
- WITHDRAWN: 'bg-orange-400',
- }
- return (
-
-
- {state.toLowerCase().replace('_', ' ')}
- {count} ({pct}%)
-
-
-
- )
- })}
-
- )}
-
-
-
-
-
- {/* ===== PROJECTS TAB ===== */}
-
-
-
-
- {/* ===== FILTERING TAB ===== */}
- {isFiltering && (
-
-
-
- )}
-
- {/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
- {isEvaluation && (
-
- {/* Coverage Report (embedded) */}
-
-
- {/* Generate Assignments */}
-
-
-
-
- Assignment Generation
-
- AI-suggested jury-to-project assignments based on expertise and workload
-
-
-
-
-
-
-
-
-
-
-
- {!juryGroup && (
-
-
- Assign a jury group first before generating assignments.
-
- )}
- {projectCount === 0 && (
-
-
- Add projects to this round first.
-
- )}
- {juryGroup && projectCount > 0 && (
-
- Click "Generate Assignments" to preview AI-suggested assignments.
- You can review and execute them from the preview sheet.
-
- )}
-
-
-
- {/* Unassigned Queue */}
-
-
- {/* Assignment Preview Sheet */}
-
-
- )}
-
- {/* ===== CONFIG TAB ===== */}
-
- {/* General Round Settings */}
-
-
- General Settings
- Settings that apply to this round regardless of type
-
-
-
-
-
-
- Send an automated email to project applicants when their project enters this round
-
-
-
{
- handleConfigChange({ ...config, notifyOnEntry: checked })
- }}
- />
-
-
-
-
- {/* Round-type-specific config */}
-
-
- {/* Document Requirements (merged from old Documents tab) */}
-
-
- Document Requirements
-
- Files applicants must submit for this round
- {round.windowCloseAt && (
- <> — due by {new Date(round.windowCloseAt).toLocaleDateString()}>
- )}
-
-
-
-
-
-
-
-
-
- )
-}
-
-// ===== Sub-component: Unassigned projects queue for evaluation rounds =====
-
-function RoundUnassignedQueue({ roundId }: { roundId: string }) {
- const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
- { roundId, requiredReviews: 3 },
- )
-
- return (
-
-
- Unassigned Projects
- Projects with fewer than 3 jury assignments
-
-
- {isLoading ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : unassigned && unassigned.length > 0 ? (
-
- {unassigned.map((project: any) => (
-
-
-
{project.title}
-
- {project.competitionCategory || 'No category'}
- {project.teamName && ` \u00b7 ${project.teamName}`}
-
-
-
- {project.assignmentCount || 0} / 3
-
-
- ))}
-
- ) : (
-
- All projects have sufficient assignments
-
- )}
-
-
- )
-}
diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx
index dae197c..6966586 100644
--- a/src/app/(admin)/admin/dashboard-content.tsx
+++ b/src/app/(admin)/admin/dashboard-content.tsx
@@ -1,6 +1,7 @@
'use client'
import Link from 'next/link'
+import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -473,7 +474,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Rounds
-
Manage competition rounds
+
Manage rounds
@@ -513,7 +514,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Rounds
- Competition rounds in {edition.name}
+ Active rounds in {edition.name}
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
-
@@ -569,7 +571,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
-
+
))}
)}
diff --git a/src/app/(admin)/admin/projects/bulk-upload/page.tsx b/src/app/(admin)/admin/projects/bulk-upload/page.tsx
index 7b07d08..0421210 100644
--- a/src/app/(admin)/admin/projects/bulk-upload/page.tsx
+++ b/src/app/(admin)/admin/projects/bulk-upload/page.tsx
@@ -60,7 +60,7 @@ type UploadState = {
type UploadMap = Record
export default function BulkUploadPage() {
- const [windowId, setWindowId] = useState('')
+ const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
@@ -96,20 +96,20 @@ export default function BulkUploadPage() {
}, [])
// Queries
- const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
+ const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
- const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
+ const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
{
- submissionWindowId: windowId,
+ roundId,
search: debouncedSearch || undefined,
status: statusFilter,
page,
pageSize: perPage,
},
- { enabled: !!windowId }
+ { enabled: !!roundId }
)
- const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
+ const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
// Upload a single file for a project requirement
const uploadFileForRequirement = useCallback(
@@ -117,7 +117,7 @@ export default function BulkUploadPage() {
projectId: string,
requirementId: string,
file: File,
- submissionWindowId: string
+ targetRoundId: string
) => {
const key = `${projectId}:${requirementId}`
setUploads((prev) => ({
@@ -131,8 +131,8 @@ export default function BulkUploadPage() {
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
- submissionWindowId,
- submissionFileRequirementId: requirementId,
+ roundId: targetRoundId,
+ requirementId,
})
// XHR upload with progress
@@ -186,18 +186,18 @@ export default function BulkUploadPage() {
}
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
- if (file && windowId) {
- uploadFileForRequirement(projectId, requirementId, file, windowId)
+ if (file && roundId) {
+ uploadFileForRequirement(projectId, requirementId, file, roundId)
}
}
input.click()
},
- [windowId, uploadFileForRequirement]
+ [roundId, uploadFileForRequirement]
)
// Handle bulk row upload
const handleBulkUploadAll = useCallback(async () => {
- if (!bulkProject || !windowId) return
+ if (!bulkProject || !roundId) return
const entries = Object.entries(bulkFiles).filter(
([, file]) => file !== null
@@ -211,14 +211,14 @@ export default function BulkUploadPage() {
// Upload all in parallel
await Promise.allSettled(
entries.map(([reqId, file]) =>
- uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
+ uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
)
)
setBulkProject(null)
setBulkFiles({})
toast.success('Bulk upload complete')
- }, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
+ }, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
const progressPercent =
data && data.totalProjects > 0
@@ -242,32 +242,37 @@ export default function BulkUploadPage() {
- {/* Window Selector */}
+ {/* Round Selector */}
- Submission Window
+ Round
- {windowsLoading ? (
+ {roundsLoading ? (
+ ) : !rounds || rounds.length === 0 ? (
+
+
+
No rounds have file requirements configured. Add file requirements to a round first.
+
) : (
- {/* Content (only if window selected) */}
- {windowId && data && (
+ {/* Content (only if round selected) */}
+ {roundId && data && (
<>
{/* Progress Summary */}
diff --git a/src/app/(admin)/admin/projects/import/page.tsx b/src/app/(admin)/admin/projects/import/page.tsx
index 579a562..797283f 100644
--- a/src/app/(admin)/admin/projects/import/page.tsx
+++ b/src/app/(admin)/admin/projects/import/page.tsx
@@ -92,7 +92,7 @@ function ImportPageContent() {
Create a competition with rounds before importing projects
) : (
diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
index d4bb546..2e50c97 100644
--- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
@@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
@@ -147,6 +148,11 @@ export default function RoundDetailPage() {
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
+ const [aiRecommendations, setAiRecommendations] = useState<{
+ STARTUP: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
+ BUSINESS_CONCEPT: Array<{ projectId: string; rank: number; score: number; category: string; strengths: string[]; concerns: string[]; recommendation: string }>
+ } | null>(null)
+ const [shortlistDialogOpen, setShortlistDialogOpen] = useState(false)
const utils = trpc.useUtils()
@@ -243,6 +249,25 @@ export default function RoundDetailPage() {
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 || archiveMutation.isPending
const handleConfigChange = useCallback((newConfig: Record) => {
@@ -828,6 +853,25 @@ export default function RoundDetailPage() {
+ {/* AI Shortlist Recommendations */}
+ {(isEvaluation || isFiltering) && projectCount > 0 && (
+
+ )}
+
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (