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 ===== */} -
- - -
-
- - Projects -
-
-

{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. +
) : ( { + const val = e.target.value ? parseInt(e.target.value, 10) : undefined + handleConfigChange({ ...config, startupAdvanceCount: val }) + }} + /> + +
+ + { + const val = e.target.value ? parseInt(e.target.value, 10) : undefined + handleConfigChange({ ...config, conceptAdvanceCount: val }) + }} + /> +
+ +
@@ -1585,6 +1741,304 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) { // ── Evaluation Criteria Editor ─────────────────────────────────────────── +// ── Advance Projects Dialog ───────────────────────────────────────────── + +function AdvanceProjectsDialog({ + open, + onOpenChange, + roundId, + projectStates, + config, + advanceMutation, +}: { + open: boolean + onOpenChange: (open: boolean) => void + roundId: string + projectStates: any[] | undefined + config: Record + advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean } +}) { + const passedProjects = useMemo(() => + (projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'), + [projectStates]) + + const startups = useMemo(() => + passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'), + [passedProjects]) + + const concepts = useMemo(() => + passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'), + [passedProjects]) + + const other = useMemo(() => + passedProjects.filter((ps: any) => + ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT', + ), + [passedProjects]) + + const startupCap = (config.startupAdvanceCount as number) || 0 + const conceptCap = (config.conceptAdvanceCount as number) || 0 + + const [selected, setSelected] = useState>(new Set()) + + // Reset selection when dialog opens + if (open && selected.size === 0 && passedProjects.length > 0) { + const initial = new Set() + // Auto-select all (or up to cap if configured) + const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups + const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts + for (const ps of startupSlice) initial.add(ps.project?.id) + for (const ps of conceptSlice) initial.add(ps.project?.id) + for (const ps of other) initial.add(ps.project?.id) + setSelected(initial) + } + + const toggleProject = (projectId: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(projectId)) next.delete(projectId) + else next.add(projectId) + return next + }) + } + + const toggleAll = (projects: any[], on: boolean) => { + setSelected((prev) => { + const next = new Set(prev) + for (const ps of projects) { + if (on) next.add(ps.project?.id) + else next.delete(ps.project?.id) + } + return next + }) + } + + const handleAdvance = () => { + const ids = Array.from(selected) + if (ids.length === 0) return + advanceMutation.mutate({ roundId, projectIds: ids }) + onOpenChange(false) + setSelected(new Set()) + } + + const handleClose = () => { + onOpenChange(false) + setSelected(new Set()) + } + + const renderCategorySection = ( + label: string, + projects: any[], + cap: number, + badgeColor: string, + ) => { + const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length + const overCap = cap > 0 && selectedInCategory > cap + + return ( +
+
+
+ 0 && projects.every((ps: any) => selected.has(ps.project?.id))} + onCheckedChange={(checked) => toggleAll(projects, !!checked)} + /> + {label} + + {selectedInCategory}/{projects.length} + + {cap > 0 && ( + + (target: {cap}) + + )} +
+
+ {projects.length === 0 ? ( +

No passed projects in this category

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

Strengths

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

Concerns

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

Recommendation

+

{item.recommendation}

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

+
+ Startup ({recommendations.STARTUP.length}) +

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

+
+ Business Concept ({recommendations.BUSINESS_CONCEPT.length}) +

+ {renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')} +
+
+
+
+ ) +} + +// ── Evaluation Criteria Editor ─────────────────────────────────────────── + function EvaluationCriteriaEditor({ roundId }: { roundId: string }) { const [editing, setEditing] = useState(false) const [criteria, setCriteria] = useState onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })} /> -
- onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })} - /> - -
@@ -116,7 +115,7 @@ export function CompetitionTimeline({ return (
diff --git a/src/components/admin/jury/add-member-dialog.tsx b/src/components/admin/jury/add-member-dialog.tsx index b62286f..67d6bb3 100644 --- a/src/components/admin/jury/add-member-dialog.tsx +++ b/src/components/admin/jury/add-member-dialog.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Search } from 'lucide-react' +import { Search, UserPlus, Mail } from 'lucide-react' import { toast } from 'sonner' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' interface AddMemberDialogProps { juryGroupId: string @@ -30,10 +31,22 @@ interface AddMemberDialogProps { } export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) { + const [tab, setTab] = useState<'search' | 'invite'>('search') + + // Search existing user state const [searchQuery, setSearchQuery] = useState('') const [selectedUserId, setSelectedUserId] = useState('') const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') const [maxAssignments, setMaxAssignments] = useState('') + const [capMode, setCapMode] = useState('') + + // Invite new user state + const [inviteName, setInviteName] = useState('') + const [inviteEmail, setInviteEmail] = useState('') + const [inviteRole, setInviteRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER') + const [inviteMaxAssignments, setInviteMaxAssignments] = useState('') + const [inviteCapMode, setInviteCapMode] = useState('') + const [inviteExpertise, setInviteExpertise] = useState('') const utils = trpc.useUtils() @@ -44,7 +57,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi const users = userResponse?.users || [] - const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({ + const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({ onSuccess: () => { utils.juryGroup.getById.invalidate({ id: juryGroupId }) toast.success('Member added successfully') @@ -56,14 +69,49 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi }, }) + const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({ + onSuccess: (newUser) => { + // Immediately add the newly created user to the jury group + addMember({ + juryGroupId, + userId: newUser.id, + role: inviteRole, + maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null, + capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null, + }) + // Send invitation email + sendInvitation({ userId: newUser.id, juryGroupId }) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({ + onSuccess: (result) => { + toast.success(`Invitation sent to ${result.email}`) + }, + onError: (err) => { + // Don't block — user was created and added, just invitation failed + toast.error(`Member added but invitation email failed: ${err.message}`) + }, + }) + const resetForm = () => { setSearchQuery('') setSelectedUserId('') setRole('MEMBER') setMaxAssignments('') + setCapMode('') + setInviteName('') + setInviteEmail('') + setInviteRole('MEMBER') + setInviteMaxAssignments('') + setInviteCapMode('') + setInviteExpertise('') } - const handleSubmit = (e: React.FormEvent) => { + const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault() if (!selectedUserId) { @@ -76,92 +124,253 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi userId: selectedUserId, role, maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null, + capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null, }) } + const handleInviteSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!inviteEmail.trim()) { + toast.error('Please enter an email address') + return + } + + const expertiseTags = inviteExpertise + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + + createUser({ + email: inviteEmail.trim(), + name: inviteName.trim() || undefined, + role: 'JURY_MEMBER', + expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined, + maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined, + }) + } + + const isPending = isAdding || isCreating + return ( - + Add Member to Jury Group - Search for a user and assign them to this jury group + Search for an existing user or invite a new juror to the platform -
-
- -
- - setSearchQuery(e.target.value)} - /> -
- {isSearching && ( -

Searching...

- )} - {users && users.length > 0 && ( -
- {users.map((user) => ( - - ))} + setTab(v as 'search' | 'invite')}> + + + + Search Existing + + + + Invite New + + + + {/* Search existing user tab */} + + +
+ +
+ + setSearchQuery(e.target.value)} + /> +
+ {isSearching && ( +

Searching...

+ )} + {users && users.length > 0 && ( +
+ {users.map((user) => ( + + ))} +
+ )}
- )} -
-
- - -
+
+
+ + +
-
- - setMaxAssignments(e.target.value)} - /> -
+
+ + +
+
- - - - - +
+ + setMaxAssignments(e.target.value)} + /> +
+ + + + + + + + + {/* Invite new user tab */} + +
+
+
+ + setInviteName(e.target.value)} + /> +
+ +
+ + setInviteEmail(e.target.value)} + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + setInviteMaxAssignments(e.target.value)} + /> +
+ +
+ + setInviteExpertise(e.target.value)} + /> +

+ Comma-separated tags for smart assignment matching +

+
+ +
+

+ + This will create a new user account and send an invitation email to join the platform as a jury member. +

+
+ + + + + +
+
+
) diff --git a/src/components/admin/jury/jury-members-table.tsx b/src/components/admin/jury/jury-members-table.tsx index 0b66d69..7f1e057 100644 --- a/src/components/admin/jury/jury-members-table.tsx +++ b/src/components/admin/jury/jury-members-table.tsx @@ -36,6 +36,7 @@ interface JuryMember { email: string } maxAssignmentsOverride: number | null + capModeOverride: string | null preferredStartupRatio: number | null } @@ -82,13 +83,14 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps Email Role Max Assignments + Cap Mode Actions {members.length === 0 ? ( - + No members yet. Add members to get started. @@ -109,6 +111,15 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps {member.maxAssignmentsOverride ?? '—'} + + {member.capModeOverride ? ( + + {member.capModeOverride} + + ) : ( + Group default + )} +