From f26ee3f07655d33dd8486fb4b3dd712d4228a935 Mon Sep 17 00:00:00 2001
From: Matt
Date: Sun, 22 Feb 2026 17:14:00 +0100
Subject: [PATCH] Admin dashboard & round management UX overhaul
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6
---
.../[competitionId]/assignments/page.tsx | 206 --
.../[competitionId]/awards/[awardId]/page.tsx | 154 -
.../[competitionId]/awards/new/page.tsx | 199 --
.../[competitionId]/awards/page.tsx | 104 -
.../deliberation/[sessionId]/page.tsx | 255 --
.../[competitionId]/deliberation/page.tsx | 411 ---
.../juries/[juryGroupId]/page.tsx | 142 -
.../[competitionId]/juries/page.tsx | 187 --
.../[competitionId]/live/[roundId]/page.tsx | 37 -
.../competitions/[competitionId]/page.tsx | 585 ----
.../(admin)/admin/competitions/new/page.tsx | 307 --
src/app/(admin)/admin/competitions/page.tsx | 201 --
src/app/(admin)/admin/dashboard-content.tsx | 130 +-
src/app/(admin)/admin/juries/page.tsx | 93 +-
src/app/(admin)/admin/programs/[id]/page.tsx | 23 +-
src/app/(admin)/admin/projects/page.tsx | 2 +-
src/app/(admin)/admin/projects/pool/page.tsx | 558 ----
.../(admin)/admin/rounds/[roundId]/page.tsx | 2942 ++---------------
src/app/(admin)/admin/rounds/page.tsx | 55 +-
.../admin/assignment/coi-review-section.tsx | 170 +
.../individual-assignments-table.tsx | 854 +++++
.../admin/assignment/jury-progress-table.tsx | 180 +
.../admin/assignment/notify-jurors-button.tsx | 61 +
.../admin/assignment/reassignment-history.tsx | 111 +
.../assignment/round-unassigned-queue.tsx | 66 +
.../assignment/send-reminders-button.tsx | 61 +
.../transfer-assignments-dialog.tsx | 328 ++
.../competition/competition-timeline.tsx | 26 +-
.../competition/sections/review-section.tsx | 13 +-
.../admin/jury/inline-member-cap.tsx | 164 +
.../admin/program/competition-settings.tsx | 165 +
.../admin/round/advance-projects-dialog.tsx | 289 ++
.../round/ai-recommendations-display.tsx | 231 ++
.../round/evaluation-criteria-editor.tsx | 124 +
.../admin/round/export-evaluations-dialog.tsx | 43 +
.../admin/round/project-states-table.tsx | 2 +-
.../admin/round/score-distribution.tsx | 64 +
.../rounds/config/config-section-header.tsx | 51 +
.../dashboard/active-round-panel.tsx | 33 +-
.../dashboard/competition-pipeline.tsx | 51 +-
.../dashboard/pipeline-round-node.tsx | 152 +-
.../dashboard/project-list-compact.tsx | 154 +-
.../dashboard/round-stats-deliberation.tsx | 99 +
.../dashboard/round-stats-live-final.tsx | 105 +
.../dashboard/round-stats-mentoring.tsx | 99 +
.../dashboard/round-stats-submission.tsx | 109 +
...ts-generic.tsx => round-stats-summary.tsx} | 45 +-
src/components/dashboard/round-stats.tsx | 31 +-
src/lib/round-config.ts | 264 ++
src/server/routers/dashboard.ts | 57 +-
src/server/routers/juryGroup.ts | 13 +
51 files changed, 4530 insertions(+), 6276 deletions(-)
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/awards/[awardId]/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/juries/[juryGroupId]/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/juries/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/live/[roundId]/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/[competitionId]/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/new/page.tsx
delete mode 100644 src/app/(admin)/admin/competitions/page.tsx
delete mode 100644 src/app/(admin)/admin/projects/pool/page.tsx
create mode 100644 src/components/admin/assignment/coi-review-section.tsx
create mode 100644 src/components/admin/assignment/individual-assignments-table.tsx
create mode 100644 src/components/admin/assignment/jury-progress-table.tsx
create mode 100644 src/components/admin/assignment/notify-jurors-button.tsx
create mode 100644 src/components/admin/assignment/reassignment-history.tsx
create mode 100644 src/components/admin/assignment/round-unassigned-queue.tsx
create mode 100644 src/components/admin/assignment/send-reminders-button.tsx
create mode 100644 src/components/admin/assignment/transfer-assignments-dialog.tsx
create mode 100644 src/components/admin/jury/inline-member-cap.tsx
create mode 100644 src/components/admin/program/competition-settings.tsx
create mode 100644 src/components/admin/round/advance-projects-dialog.tsx
create mode 100644 src/components/admin/round/ai-recommendations-display.tsx
create mode 100644 src/components/admin/round/evaluation-criteria-editor.tsx
create mode 100644 src/components/admin/round/export-evaluations-dialog.tsx
create mode 100644 src/components/admin/round/score-distribution.tsx
create mode 100644 src/components/admin/rounds/config/config-section-header.tsx
create mode 100644 src/components/dashboard/round-stats-deliberation.tsx
create mode 100644 src/components/dashboard/round-stats-live-final.tsx
create mode 100644 src/components/dashboard/round-stats-mentoring.tsx
create mode 100644 src/components/dashboard/round-stats-submission.tsx
rename src/components/dashboard/{round-stats-generic.tsx => round-stats-summary.tsx} (66%)
create mode 100644 src/lib/round-config.ts
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
deleted file mode 100644
index b3edd71..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useParams, useRouter } from 'next/navigation'
-import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
-import { toast } from 'sonner'
-import { trpc } from '@/lib/trpc/client'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Skeleton } from '@/components/ui/skeleton'
-import { CoverageReport } from '@/components/admin/assignment/coverage-report'
-import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-
-export default function AssignmentsDashboardPage() {
- const params = useParams()
- const router = useRouter()
- const competitionId = params.competitionId as string
-
- const [selectedRoundId, setSelectedRoundId] = useState('')
- const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
-
- const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
- onSuccess: () => {
- toast.success('AI assignments ready!', {
- action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
- duration: 10000,
- })
- },
- onError: (err) => toast.error(`AI generation failed: ${err.message}`),
- })
-
- const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
- id: competitionId,
- })
-
- const { data: selectedRound } = trpc.round.getById.useQuery(
- { id: selectedRoundId },
- { enabled: !!selectedRoundId }
- )
-
- const requiredReviews = (selectedRound?.configJson as Record)?.requiredReviewsPerProject as number || 3
-
- const { data: unassignedQueue, isLoading: isLoadingQueue } =
- trpc.roundAssignment.unassignedQueue.useQuery(
- { roundId: selectedRoundId, requiredReviews },
- { enabled: !!selectedRoundId }
- )
-
- const rounds = competition?.rounds || []
- const currentRound = rounds.find((r) => r.id === selectedRoundId)
-
- if (isLoadingCompetition) {
- return (
-
-
-
-
- )
- }
-
- if (!competition) {
- return (
-
-
Competition not found
-
- )
- }
-
- return (
-
-
router.back()} className="mb-4" aria-label="Back to competition details">
-
- Back to Competition
-
-
-
-
-
Assignment Dashboard
-
Manage jury assignments for rounds
-
-
-
-
-
- Select Round
- Choose a round to view and manage assignments
-
-
-
-
-
-
-
- {rounds.length === 0 ? (
- No rounds available
- ) : (
- rounds.map((round) => (
-
- {round.name} ({round.roundType})
-
- ))
- )}
-
-
-
-
-
- {selectedRoundId && (
-
-
- {
- aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
- }}
- disabled={aiAssignmentMutation.isPending}
- >
- {aiAssignmentMutation.isPending ? (
- <> Generating...>
- ) : (
- <> {aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}>
- )}
-
- {aiAssignmentMutation.data && (
- setPreviewSheetOpen(true)}>
- Review Assignments
-
- )}
-
-
-
-
- Coverage Report
- Unassigned Queue
-
-
-
-
-
-
-
-
-
- Unassigned Projects
-
- Projects with fewer than {requiredReviews} assignments
-
-
-
- {isLoadingQueue ? (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
- ) : unassignedQueue && unassignedQueue.length > 0 ? (
-
- {unassignedQueue.map((project: any) => (
-
-
-
{project.title}
-
- {project.competitionCategory || 'No category'}
-
-
-
- {project.assignmentCount || 0} / {requiredReviews} assignments
-
-
- ))}
-
- ) : (
-
- All projects have sufficient assignments
-
- )}
-
-
-
-
-
-
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
- onResetAI={() => aiAssignmentMutation.reset()}
- />
-
- )}
-
- )
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/awards/[awardId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/awards/[awardId]/page.tsx
deleted file mode 100644
index 63c1543..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/awards/[awardId]/page.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-'use client';
-
-import { use } from 'react';
-import { useRouter } from 'next/navigation';
-import { trpc } from '@/lib/trpc/client';
-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 { Badge } from '@/components/ui/badge';
-import { ArrowLeft } from 'lucide-react';
-import type { Route } from 'next';
-
-export default function AwardDetailPage({
- params: paramsPromise
-}: {
- params: Promise<{ competitionId: string; awardId: string }>;
-}) {
- const params = use(paramsPromise);
- const router = useRouter();
- const { data: award, isLoading } = trpc.specialAward.get.useQuery({
- id: params.awardId
- });
-
- if (isLoading) {
- return (
-
-
-
router.back()}>
-
-
-
-
Loading...
-
-
-
- );
- }
-
- if (!award) {
- return (
-
-
-
router.back()}>
-
-
-
-
Award Not Found
-
-
-
- );
- }
-
- return (
-
-
-
- router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
- }
- >
-
-
-
-
{award.name}
-
{award.description || 'No description'}
-
-
-
-
-
- Overview
- Eligible Projects
- Winners
-
-
-
-
-
- Award Information
- Configuration and settings
-
-
-
-
-
Scoring Mode
-
- {award.scoringMode}
-
-
-
-
AI Eligibility
-
- {award.useAiEligibility ? 'Enabled' : 'Disabled'}
-
-
-
-
Status
-
- {award.status}
-
-
-
-
Program
-
{award.program?.name}
-
-
-
-
-
-
-
-
-
- Eligible Projects
-
- Projects that qualify for this award ({award?.eligibleCount || 0})
-
-
-
-
- {award?.eligibleCount || 0} eligible projects
-
-
-
-
-
-
-
-
- Award Winners
- Selected winners for this award
-
-
- {award?.winnerProject ? (
-
-
-
{award.winnerProject.title}
-
{award.winnerProject.teamName}
-
-
Winner
-
- ) : (
- No winner selected yet
- )}
-
-
-
-
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx
deleted file mode 100644
index 9112d0f..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-'use client';
-
-import { use, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { trpc } from '@/lib/trpc/client';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Textarea } from '@/components/ui/textarea';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Checkbox } from '@/components/ui/checkbox';
-import { ArrowLeft } from 'lucide-react';
-import { toast } from 'sonner';
-import type { Route } from 'next';
-
-export default function NewAwardPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
- const params = use(paramsPromise);
- const router = useRouter();
- const utils = trpc.useUtils();
-
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- criteriaText: '',
- useAiEligibility: false,
- scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
- maxRankedPicks: '3',
- });
-
- const { data: competition } = trpc.competition.getById.useQuery({
- id: params.competitionId
- });
-
- const { data: juryGroups } = trpc.juryGroup.list.useQuery({
- competitionId: params.competitionId
- });
-
- const createMutation = trpc.specialAward.create.useMutation({
- onSuccess: () => {
- utils.specialAward.list.invalidate();
- toast.success('Award created successfully');
- router.push(`/admin/competitions/${params.competitionId}/awards` as Route);
- },
- onError: (err) => {
- toast.error(err.message);
- }
- });
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!formData.name.trim()) {
- toast.error('Award name is required');
- return;
- }
-
- if (!competition?.programId) {
- toast.error('Competition data not loaded');
- return;
- }
-
- createMutation.mutate({
- programId: competition.programId,
- competitionId: params.competitionId,
- name: formData.name.trim(),
- description: formData.description.trim() || undefined,
- criteriaText: formData.criteriaText.trim() || undefined,
- scoringMode: formData.scoringMode,
- useAiEligibility: formData.useAiEligibility,
- maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
- });
- };
-
- return (
-
-
-
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
- >
-
-
-
-
Create Special Award
-
Define a new award for this competition
-
-
-
-
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
deleted file mode 100644
index e92d644..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client';
-
-import { use } from 'react';
-import { useRouter } from 'next/navigation';
-import Link from 'next/link';
-import { trpc } from '@/lib/trpc/client';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { ArrowLeft, Plus } from 'lucide-react';
-import type { Route } from 'next';
-
-export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
- const params = use(paramsPromise);
- const router = useRouter();
- const { data: competition } = trpc.competition.getById.useQuery({
- id: params.competitionId
- });
-
- const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
- programId: competition?.programId
- }, {
- enabled: !!competition?.programId
- });
-
- if (isLoading) {
- return (
-
-
-
router.back()}>
-
-
-
-
Special Awards
-
Loading...
-
-
-
- );
- }
-
- return (
-
-
-
-
router.back()}>
-
-
-
-
Special Awards
-
- Manage special awards and prizes for this competition
-
-
-
-
-
-
- Create Award
-
-
-
-
-
- {awards?.map((award) => (
-
-
-
-
- {award.name}
-
-
- {award.description || 'No description'}
-
-
-
-
- {award.scoringMode}
-
- {award.status}
-
-
-
-
-
- ))}
-
-
- {awards?.length === 0 && (
-
-
- No awards created yet
-
- Create your first award
-
-
-
- )}
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx
deleted file mode 100644
index bf5db69..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/[sessionId]/page.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-'use client';
-
-import { use, useMemo } from 'react';
-import { useRouter } from 'next/navigation';
-import { trpc } from '@/lib/trpc/client';
-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 { Badge } from '@/components/ui/badge';
-import { ArrowLeft } from 'lucide-react';
-import { toast } from 'sonner';
-import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
-import type { Route } from 'next';
-
-const STATUS_LABELS: Record = {
- DELIB_OPEN: 'Open',
- VOTING: 'Voting',
- TALLYING: 'Tallying',
- RUNOFF: 'Runoff',
- DELIB_LOCKED: 'Locked',
-};
-const STATUS_VARIANTS: Record = {
- DELIB_OPEN: 'outline',
- VOTING: 'default',
- TALLYING: 'secondary',
- RUNOFF: 'secondary',
- DELIB_LOCKED: 'secondary',
-};
-const CATEGORY_LABELS: Record = {
- STARTUP: 'Startup',
- BUSINESS_CONCEPT: 'Business Concept',
-};
-const TIE_BREAK_LABELS: Record = {
- TIE_RUNOFF: 'Runoff Vote',
- TIE_ADMIN_DECIDES: 'Admin Decides',
- SCORE_FALLBACK: 'Score Fallback',
-};
-
-export default function DeliberationSessionPage({
- params: paramsPromise
-}: {
- params: Promise<{ competitionId: string; sessionId: string }>;
-}) {
- const params = use(paramsPromise);
- const router = useRouter();
- const utils = trpc.useUtils();
-
- const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
- { sessionId: params.sessionId },
- { refetchInterval: 10_000 }
- );
-
- const openVotingMutation = trpc.deliberation.openVoting.useMutation({
- onSuccess: () => {
- utils.deliberation.getSession.invalidate();
- toast.success('Voting opened');
- },
- onError: (err) => {
- toast.error(err.message);
- }
- });
-
- const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
- onSuccess: () => {
- utils.deliberation.getSession.invalidate();
- toast.success('Voting closed');
- },
- onError: (err) => {
- toast.error(err.message);
- }
- });
-
- // Derive which participants have voted from the votes array
- const voterUserIds = useMemo(() => {
- if (!session?.votes) return new Set();
- return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
- }, [session?.votes]);
-
- if (isLoading) {
- return (
-
-
-
router.back()}>
-
-
-
-
Loading...
-
-
-
- );
- }
-
- if (!session) {
- return (
-
-
-
router.back()}>
-
-
-
-
Session Not Found
-
-
-
- );
- }
-
- return (
-
-
-
- router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
- }
- >
-
-
-
-
-
Deliberation Session
- {STATUS_LABELS[session.status] ?? session.status}
-
-
- {session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
-
-
-
-
-
-
- Setup
- Voting Control
- Results
-
-
-
-
-
- Session Configuration
- Deliberation settings and participants
-
-
-
-
-
Mode
-
- {session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
-
-
-
-
Tie Break Method
-
{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}
-
-
-
- Show Collective Rankings
-
-
{session.showCollectiveRankings ? 'Yes' : 'No'}
-
-
-
Show Prior Jury Data
-
{session.showPriorJuryData ? 'Yes' : 'No'}
-
-
-
-
-
-
-
- Participants ({session.participants?.length || 0})
-
-
-
- {session.participants?.map((participant: any) => (
-
-
-
{participant.user?.user?.name ?? 'Unknown'}
-
{participant.user?.user?.email}
-
-
- {voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
-
-
- ))}
-
-
-
-
-
-
-
-
- Voting Controls
- Manage the voting window for jury members
-
-
-
- openVotingMutation.mutate({ sessionId: params.sessionId })}
- disabled={
- openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
- }
- className="flex-1"
- >
- Open Voting
-
- closeVotingMutation.mutate({ sessionId: params.sessionId })}
- disabled={
- closeVotingMutation.isPending || session.status !== 'VOTING'
- }
- className="flex-1"
- >
- Close Voting
-
-
-
-
-
-
-
- Voting Status
-
-
-
- {session.participants?.map((participant: any) => (
-
- {participant.user?.user?.name ?? 'Unknown'}
-
- {voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
deleted file mode 100644
index b52a7b1..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
+++ /dev/null
@@ -1,411 +0,0 @@
-'use client';
-
-import { use, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import Link from 'next/link';
-import { trpc } from '@/lib/trpc/client';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog';
-import { Label } from '@/components/ui/label';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Badge } from '@/components/ui/badge';
-import { Skeleton } from '@/components/ui/skeleton';
-import { ArrowLeft, Plus } from 'lucide-react';
-import { toast } from 'sonner';
-import type { Route } from 'next';
-
-export default function DeliberationListPage({
- params: paramsPromise
-}: {
- params: Promise<{ competitionId: string }>;
-}) {
- const params = use(paramsPromise);
- const router = useRouter();
- const utils = trpc.useUtils();
- const [createDialogOpen, setCreateDialogOpen] = useState(false);
- const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
- const [formData, setFormData] = useState({
- roundId: '',
- category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
- mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
- tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
- showCollectiveRankings: false,
- showPriorJuryData: false,
- participantUserIds: [] as string[]
- });
-
- const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
- { competitionId: params.competitionId },
- { enabled: !!params.competitionId }
- );
-
- // Get rounds for this competition
- const { data: competition } = trpc.competition.getById.useQuery(
- { id: params.competitionId },
- { enabled: !!params.competitionId }
- );
- const rounds = competition?.rounds || [];
-
- // Jury groups & members for participant selection
- const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
- { competitionId: params.competitionId },
- { enabled: !!params.competitionId }
- );
-
- const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
- { id: selectedJuryGroupId },
- { enabled: !!selectedJuryGroupId }
- );
- const juryMembers = selectedJuryGroup?.members ?? [];
-
- const createSessionMutation = trpc.deliberation.createSession.useMutation({
- onSuccess: (data) => {
- utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
- toast.success('Deliberation session created');
- setCreateDialogOpen(false);
- router.push(
- `/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
- );
- },
- onError: (err) => {
- toast.error(err.message);
- }
- });
-
- const handleCreateSession = () => {
- if (!formData.roundId) {
- toast.error('Please select a round');
- return;
- }
- if (formData.participantUserIds.length === 0) {
- toast.error('Please select at least one participant');
- return;
- }
-
- createSessionMutation.mutate({
- competitionId: params.competitionId,
- roundId: formData.roundId,
- category: formData.category,
- mode: formData.mode,
- tieBreakMethod: formData.tieBreakMethod,
- showCollectiveRankings: formData.showCollectiveRankings,
- showPriorJuryData: formData.showPriorJuryData,
- participantUserIds: formData.participantUserIds
- });
- };
-
- const getStatusBadge = (status: string) => {
- const variants: Record = {
- DELIB_OPEN: 'outline',
- VOTING: 'default',
- TALLYING: 'secondary',
- RUNOFF: 'secondary',
- DELIB_LOCKED: 'secondary',
- };
- const labels: Record = {
- DELIB_OPEN: 'Open',
- VOTING: 'Voting',
- TALLYING: 'Tallying',
- RUNOFF: 'Runoff',
- DELIB_LOCKED: 'Locked',
- };
- return {labels[status] || status} ;
- };
-
- if (isLoading) {
- return (
-
-
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
-
- );
- }
-
- return (
-
-
-
-
router.back()} aria-label="Back to competition details">
-
-
-
-
Deliberation Sessions
-
- Manage final jury deliberations and winner selection
-
-
-
-
setCreateDialogOpen(true)}>
-
- New Session
-
-
-
-
- {sessions?.map((session: any) => (
-
-
-
-
-
-
- {session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
-
-
- {session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
-
-
- {getStatusBadge(session.status)}
-
-
-
-
- {session.participants?.length || 0} participants
- •
- Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}
-
-
-
-
- ))}
-
-
- {sessions?.length === 0 && (
-
-
- No deliberation sessions yet
- setCreateDialogOpen(true)}>
- Create your first session
-
-
-
- )}
-
- {/* Create Session Dialog */}
-
-
-
- Create Deliberation Session
-
- Set up a new deliberation session for final winner selection
-
-
-
-
-
- Round *
- setFormData({ ...formData, roundId: value })}>
-
-
-
-
- {rounds?.map((round: any) => (
-
- {round.name}
-
- ))}
-
-
-
-
-
-
- Category *
-
- setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
- }
- >
-
-
-
-
- Startup
- Business Concept
-
-
-
-
-
- Voting Mode *
-
- setFormData({
- ...formData,
- mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
- })
- }
- >
-
-
-
-
- Single Winner Vote
- Full Ranking
-
-
-
-
-
-
- Tie Break Method *
-
- setFormData({
- ...formData,
- tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
- })
- }
- >
-
-
-
-
- Runoff Vote
- Admin Decides
- Score Fallback
-
-
-
-
- {/* Participant Selection */}
-
- Jury Group *
- {
- setSelectedJuryGroupId(value);
- setFormData({ ...formData, participantUserIds: [] });
- }}
- >
-
-
-
-
- {juryGroups.map((group: any) => (
-
- {group.name} ({group._count?.members ?? 0} members)
-
- ))}
-
-
-
-
- {juryMembers.length > 0 && (
-
-
- Participants ({formData.participantUserIds.length}/{juryMembers.length})
- {
- const allIds = juryMembers.map((m: any) => m.user.id);
- const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
- setFormData({
- ...formData,
- participantUserIds: allSelected ? [] : allIds,
- });
- }}
- >
- {juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
- ? 'Deselect All'
- : 'Select All'}
-
-
-
- {juryMembers.map((member: any) => (
-
- {
- setFormData({
- ...formData,
- participantUserIds: checked
- ? [...formData.participantUserIds, member.user.id]
- : formData.participantUserIds.filter((id: string) => id !== member.user.id),
- });
- }}
- />
-
- {member.user.name || member.user.email}
-
- {member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
-
-
-
- ))}
-
-
- )}
-
-
-
-
- setFormData({ ...formData, showCollectiveRankings: checked as boolean })
- }
- />
-
- Show collective rankings during voting
-
-
-
-
-
- setFormData({ ...formData, showPriorJuryData: checked as boolean })
- }
- />
-
- Show prior jury evaluation data
-
-
-
-
-
-
- setCreateDialogOpen(false)}>
- Cancel
-
-
- {createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
-
-
-
-
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/juries/[juryGroupId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/juries/[juryGroupId]/page.tsx
deleted file mode 100644
index 3d115c3..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/juries/[juryGroupId]/page.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-'use client'
-
-import { useParams, useRouter } from 'next/navigation'
-import { ArrowLeft } from 'lucide-react'
-import { trpc } from '@/lib/trpc/client'
-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 { JuryMembersTable } from '@/components/admin/jury/jury-members-table'
-import { Badge } from '@/components/ui/badge'
-
-export default function JuryGroupDetailPage() {
- const params = useParams()
- const router = useRouter()
- const juryGroupId = params.juryGroupId as string
-
- const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
- { id: juryGroupId },
- { refetchInterval: 30_000 }
- )
-
- if (isLoading) {
- return (
-
-
-
-
- )
- }
-
- if (!juryGroup) {
- return (
-
- )
- }
-
- return (
-
-
router.back()} className="mb-4" aria-label="Back to jury groups list">
-
- Back to Juries
-
-
-
-
-
{juryGroup.name}
-
{juryGroup.slug}
-
-
- {juryGroup.defaultCapMode}
-
-
-
-
-
- Members
- Settings
-
-
-
-
-
- Jury Members
-
- Manage the members of this jury group
-
-
-
-
-
-
-
-
-
-
-
- Jury Group Settings
-
- View and edit settings for this jury group
-
-
-
-
-
-
Name
-
{juryGroup.name}
-
-
-
Slug
-
{juryGroup.slug}
-
-
-
Default Max Assignments
-
{juryGroup.defaultMaxAssignments}
-
-
-
Default Cap Mode
- {juryGroup.defaultCapMode}
-
-
-
Soft Cap Buffer
-
{juryGroup.softCapBuffer}
-
-
-
Allow Juror Cap Adjustment
-
- {juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
-
-
-
-
Allow Ratio Adjustment
-
- {juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
-
-
-
-
Category Quotas Enabled
-
- {juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
-
-
-
-
- {juryGroup.description && (
-
-
Description
-
{juryGroup.description}
-
- )}
-
-
-
-
-
- )
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/juries/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/juries/page.tsx
deleted file mode 100644
index bbbcc64..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/juries/page.tsx
+++ /dev/null
@@ -1,187 +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 { ArrowLeft, Plus, Users } from 'lucide-react'
-import { trpc } from '@/lib/trpc/client'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Skeleton } from '@/components/ui/skeleton'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { toast } from 'sonner'
-
-export default function JuriesListPage() {
- const params = useParams()
- const router = useRouter()
- const competitionId = params.competitionId as string
- const utils = trpc.useUtils()
-
- const [createOpen, setCreateOpen] = useState(false)
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- })
-
- const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
-
- const createMutation = trpc.juryGroup.create.useMutation({
- onSuccess: () => {
- utils.juryGroup.list.invalidate({ competitionId })
- toast.success('Jury group created')
- setCreateOpen(false)
- setFormData({ name: '', description: '' })
- },
- onError: (err) => toast.error(err.message),
- })
-
- const handleCreate = () => {
- if (!formData.name.trim()) {
- toast.error('Name is required')
- return
- }
- const slug = formData.name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-|-$/g, '')
- createMutation.mutate({
- competitionId,
- name: formData.name.trim(),
- slug,
- description: formData.description.trim() || undefined,
- })
- }
-
- if (isLoading) {
- return (
-
-
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
-
- )
- }
-
- return (
-
-
router.back()}
- className="mb-4"
- aria-label="Back to competition details"
- >
-
- Back
-
-
-
-
-
Jury Groups
-
Manage jury groups and members for this competition
-
-
setCreateOpen(true)}>
-
- Create Jury Group
-
-
-
- {juryGroups && juryGroups.length === 0 ? (
-
-
-
- No jury groups yet. Create one to get started.
-
-
- ) : (
-
- {juryGroups?.map((group) => (
-
-
-
-
- {group.name}
- {group.defaultCapMode}
-
- {group.slug}
-
-
-
- Members
- {group._count.members}
-
-
- Assignments
- {group._count.assignments || 0}
-
-
- Default Max
- {group.defaultMaxAssignments}
-
-
-
-
- ))}
-
- )}
-
- {/* Create Jury Group Dialog */}
-
-
-
- Create Jury Group
-
- Create a new jury group for this competition. You can add members after creation.
-
-
-
-
- Name *
- setFormData({ ...formData, name: e.target.value })}
- />
-
-
- Description
- setFormData({ ...formData, description: e.target.value })}
- rows={3}
- />
-
-
-
- setCreateOpen(false)}>
- Cancel
-
-
- {createMutation.isPending ? 'Creating...' : 'Create'}
-
-
-
-
-
- )
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/live/[roundId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/live/[roundId]/page.tsx
deleted file mode 100644
index ccfd26a..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/live/[roundId]/page.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-'use client';
-
-import { use } from 'react';
-import { useRouter } from 'next/navigation';
-import { Button } from '@/components/ui/button';
-import { ArrowLeft } from 'lucide-react';
-import { LiveControlPanel } from '@/components/admin/live/live-control-panel';
-import type { Route } from 'next';
-
-export default function LiveFinalsPage({
- params: paramsPromise
-}: {
- params: Promise<{ competitionId: string; roundId: string }>;
-}) {
- const params = use(paramsPromise);
- const router = useRouter();
-
- return (
-
-
-
router.push(`/admin/competitions/${params.competitionId}` as Route)}
- >
-
-
-
-
Live Finals Control
-
Manage live ceremony presentation and voting
-
-
-
-
-
- );
-}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
deleted file mode 100644
index db09ecb..0000000
--- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
+++ /dev/null
@@ -1,585 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { useParams } from 'next/navigation'
-import Link from 'next/link'
-import type { Route } from 'next'
-import { trpc } from '@/lib/trpc/client'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card'
-import { Skeleton } from '@/components/ui/skeleton'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { toast } from 'sonner'
-import { cn } from '@/lib/utils'
-import {
- ArrowLeft,
- ChevronDown,
- Layers,
- Users,
- FolderKanban,
- ClipboardList,
- Settings,
- MoreHorizontal,
- Archive,
- Loader2,
- Plus,
- CalendarDays,
-} from 'lucide-react'
-import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
-
-const ROUND_TYPES = [
- { value: 'INTAKE', label: 'Intake' },
- { value: 'FILTERING', label: 'Filtering' },
- { value: 'EVALUATION', label: 'Evaluation' },
- { value: 'SUBMISSION', label: 'Submission' },
- { value: 'MENTORING', label: 'Mentoring' },
- { value: 'LIVE_FINAL', label: 'Live Final' },
- { value: 'DELIBERATION', label: 'Deliberation' },
-] as const
-
-const statusConfig = {
- DRAFT: {
- label: 'Draft',
- bgClass: 'bg-gray-100 text-gray-700',
- dotClass: 'bg-gray-500',
- },
- ACTIVE: {
- label: 'Active',
- bgClass: 'bg-emerald-100 text-emerald-700',
- dotClass: 'bg-emerald-500',
- },
- CLOSED: {
- label: 'Closed',
- bgClass: 'bg-blue-100 text-blue-700',
- dotClass: 'bg-blue-500',
- },
- ARCHIVED: {
- label: 'Archived',
- bgClass: 'bg-muted text-muted-foreground',
- dotClass: 'bg-muted-foreground',
- },
-} as const
-
-const roundTypeColors: Record = {
- INTAKE: 'bg-gray-100 text-gray-700',
- FILTERING: 'bg-amber-100 text-amber-700',
- EVALUATION: 'bg-blue-100 text-blue-700',
- SUBMISSION: 'bg-purple-100 text-purple-700',
- MENTORING: 'bg-teal-100 text-teal-700',
- LIVE_FINAL: 'bg-red-100 text-red-700',
- DELIBERATION: 'bg-indigo-100 text-indigo-700',
-}
-
-export default function CompetitionDetailPage() {
- const params = useParams()
- const competitionId = params.competitionId as string
- const utils = trpc.useUtils()
- const [addRoundOpen, setAddRoundOpen] = useState(false)
- const [roundForm, setRoundForm] = useState({
- name: '',
- roundType: '' as string,
- })
-
- const { data: competition, isLoading } = trpc.competition.getById.useQuery(
- { id: competitionId },
- { refetchInterval: 30_000 }
- )
-
- const updateMutation = trpc.competition.update.useMutation({
- onSuccess: () => {
- utils.competition.getById.invalidate({ id: competitionId })
- toast.success('Competition updated')
- },
- onError: (err) => toast.error(err.message),
- })
-
- const createRoundMutation = trpc.round.create.useMutation({
- onSuccess: () => {
- utils.competition.getById.invalidate({ id: competitionId })
- toast.success('Round created')
- setAddRoundOpen(false)
- setRoundForm({ name: '', roundType: '' })
- },
- onError: (err) => toast.error(err.message),
- })
-
- const handleStatusChange = (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
- updateMutation.mutate({ id: competitionId, status: newStatus })
- }
-
- const handleCreateRound = () => {
- if (!roundForm.name.trim() || !roundForm.roundType) {
- toast.error('Name and type are required')
- return
- }
- const slug = roundForm.name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '-')
- .replace(/^-|-$/g, '')
- const nextOrder = competition?.rounds.length ?? 0
- createRoundMutation.mutate({
- competitionId,
- name: roundForm.name.trim(),
- slug,
- roundType: roundForm.roundType as any,
- sortOrder: nextOrder,
- })
- }
-
- if (isLoading) {
- return (
-
- )
- }
-
- if (!competition) {
- return (
-
-
-
-
-
-
-
-
-
Competition Not Found
-
- The requested competition does not exist
-
-
-
-
- )
- }
-
- const status = competition.status as keyof typeof statusConfig
- const config = statusConfig[status] || statusConfig.DRAFT
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
-
{competition.name}
-
-
-
- {config.label}
-
-
-
-
- {(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
- handleStatusChange(s)}
- disabled={competition.status === s || updateMutation.isPending}
- >
- {s.charAt(0) + s.slice(1).toLowerCase()}
-
- ))}
-
- handleStatusChange('ARCHIVED')}
- disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
- >
-
- Archive
-
-
-
-
-
{competition.slug}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Assignments
-
-
-
-
-
- Deliberation
-
-
-
- handleStatusChange('ARCHIVED')}
- disabled={updateMutation.isPending}
- >
- {updateMutation.isPending ? (
-
- ) : (
-
- )}
- Archive
-
-
-
-
-
-
- {/* Stats Cards */}
-
-
-
-
-
- Rounds
-
- {competition.rounds.filter((r: any) => !r.specialAwardId).length}
-
-
-
-
-
-
- Juries
-
- {competition.juryGroups.length}
-
-
-
-
-
-
- Projects
-
-
- {(competition as any).distinctProjectCount ?? 0}
-
-
-
-
-
-
-
- Category
-
- {competition.categoryMode}
-
-
-
-
- {/* Tabs */}
-
-
- Overview
- Rounds
- Juries
- Settings
-
-
- {/* Overview Tab */}
-
- !r.specialAwardId)}
- />
-
-
- {/* Rounds Tab */}
-
-
-
Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})
-
setAddRoundOpen(true)}>
-
- Add Round
-
-
-
- {competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
-
-
- No rounds configured. Add rounds to define the competition flow.
-
-
- ) : (
-
- {competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
- const projectCount = round._count?.projectRoundStates ?? 0
- const assignmentCount = round._count?.assignments ?? 0
- const statusLabel = round.status.replace('ROUND_', '')
- const statusColors: Record
= {
- DRAFT: 'bg-gray-100 text-gray-600',
- ACTIVE: 'bg-emerald-100 text-emerald-700',
- CLOSED: 'bg-blue-100 text-blue-700',
- ARCHIVED: 'bg-muted text-muted-foreground',
- }
- return (
-
-
-
- {/* Top: number + name + badges */}
-
-
- {index + 1}
-
-
-
{round.name}
-
-
- {round.roundType.replace(/_/g, ' ')}
-
-
- {statusLabel}
-
-
-
-
-
- {/* Stats row */}
-
-
-
- {projectCount} project{projectCount !== 1 ? 's' : ''}
-
- {(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
-
-
- {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}
-
- )}
-
-
- {/* Dates */}
- {(round.windowOpenAt || round.windowCloseAt) && (
-
-
-
- {round.windowOpenAt
- ? new Date(round.windowOpenAt).toLocaleDateString()
- : '?'}
- {' \u2014 '}
- {round.windowCloseAt
- ? new Date(round.windowCloseAt).toLocaleDateString()
- : '?'}
-
-
- )}
-
- {/* Jury group */}
- {round.juryGroup && (
-
-
- {round.juryGroup.name}
-
- )}
-
-
-
- )
- })}
-
- )}
-
-
- {/* Juries Tab */}
-
-
-
Jury Groups ({competition.juryGroups.length})
-
-
-
- Manage Juries
-
-
-
-
- {competition.juryGroups.length === 0 ? (
-
-
- No jury groups configured. Create jury groups to assign evaluators.
-
-
- ) : (
-
- {competition.juryGroups.map((group) => (
-
-
-
- {group.name}
-
-
-
- {group._count.members} members
-
- Cap: {group.defaultCapMode}
-
-
-
-
-
- ))}
-
- )}
-
-
- {/* Settings Tab */}
-
-
-
- Competition Settings
-
-
-
-
-
Category Mode
-
{competition.categoryMode}
-
-
-
Startup Finalists
-
{competition.startupFinalistCount}
-
-
-
Concept Finalists
-
{competition.conceptFinalistCount}
-
-
-
Notifications
-
- {competition.notifyOnDeadlineApproach && (
- Deadline Approach
- )}
-
-
-
- {competition.deadlineReminderDays && (
-
-
- Reminder Days
-
-
- {(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
-
-
- )}
-
-
-
-
-
- {/* Add Round Dialog */}
-
-
-
- Add Round
-
- Add a new round to this competition. It will be appended to the current round sequence.
-
-
-
-
- Name *
- setRoundForm({ ...roundForm, name: e.target.value })}
- />
-
-
- Round Type *
- setRoundForm({ ...roundForm, roundType: value })}
- >
-
-
-
-
- {ROUND_TYPES.map((rt) => (
-
- {rt.label}
-
- ))}
-
-
-
-
-
- setAddRoundOpen(false)}>
- Cancel
-
-
- {createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
-
-
-
-
-
- )
-}
diff --git a/src/app/(admin)/admin/competitions/new/page.tsx b/src/app/(admin)/admin/competitions/new/page.tsx
deleted file mode 100644
index 9984ae1..0000000
--- a/src/app/(admin)/admin/competitions/new/page.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
-import Link from 'next/link'
-import type { Route } from 'next'
-import { trpc } from '@/lib/trpc/client'
-import { toast } from 'sonner'
-import { Button } from '@/components/ui/button'
-import { SidebarStepper } from '@/components/ui/sidebar-stepper'
-import type { StepConfig } from '@/components/ui/sidebar-stepper'
-import { ArrowLeft } from 'lucide-react'
-import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
-import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
-import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
-import { ReviewSection } from '@/components/admin/competition/sections/review-section'
-import { useEdition } from '@/contexts/edition-context'
-
-type WizardRound = {
- tempId: string
- name: string
- slug: string
- roundType: string
- sortOrder: number
- configJson: Record
-}
-
-type WizardJuryGroup = {
- tempId: string
- name: string
- slug: string
- defaultMaxAssignments: number
- defaultCapMode: string
- sortOrder: number
-}
-
-type WizardState = {
- programId: string
- name: string
- slug: string
- categoryMode: string
- startupFinalistCount: number
- conceptFinalistCount: number
- notifyOnRoundAdvance: boolean
- notifyOnDeadlineApproach: boolean
- deadlineReminderDays: number[]
- rounds: WizardRound[]
- juryGroups: WizardJuryGroup[]
-}
-
-const defaultRounds: WizardRound[] = [
- {
- tempId: crypto.randomUUID(),
- name: 'Intake',
- slug: 'intake',
- roundType: 'INTAKE',
- sortOrder: 0,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Filtering',
- slug: 'filtering',
- roundType: 'FILTERING',
- sortOrder: 1,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Evaluation (Jury 1)',
- slug: 'evaluation-jury-1',
- roundType: 'EVALUATION',
- sortOrder: 2,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Submission',
- slug: 'submission',
- roundType: 'SUBMISSION',
- sortOrder: 3,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Evaluation (Jury 2)',
- slug: 'evaluation-jury-2',
- roundType: 'EVALUATION',
- sortOrder: 4,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Mentoring',
- slug: 'mentoring',
- roundType: 'MENTORING',
- sortOrder: 5,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Live Final',
- slug: 'live-final',
- roundType: 'LIVE_FINAL',
- sortOrder: 6,
- configJson: {},
- },
- {
- tempId: crypto.randomUUID(),
- name: 'Deliberation',
- slug: 'deliberation',
- roundType: 'DELIBERATION',
- sortOrder: 7,
- configJson: {},
- },
-]
-
-export default function NewCompetitionPage() {
- const router = useRouter()
- const searchParams = useSearchParams()
- const { currentEdition } = useEdition()
- const paramProgramId = searchParams.get('programId')
- const programId = paramProgramId || currentEdition?.id || ''
-
- const [currentStep, setCurrentStep] = useState(0)
- const [isDirty, setIsDirty] = useState(false)
-
- const [state, setState] = useState({
- programId,
- name: '',
- slug: '',
- categoryMode: 'SHARED',
- startupFinalistCount: 3,
- conceptFinalistCount: 3,
- notifyOnRoundAdvance: true,
- notifyOnDeadlineApproach: true,
- deadlineReminderDays: [7, 3, 1],
- rounds: defaultRounds,
- juryGroups: [],
- })
-
- useEffect(() => {
- if (programId) {
- setState((prev) => ({ ...prev, programId }))
- }
- }, [programId])
-
- useEffect(() => {
- const handleBeforeUnload = (e: BeforeUnloadEvent) => {
- if (isDirty) {
- e.preventDefault()
- e.returnValue = ''
- }
- }
- window.addEventListener('beforeunload', handleBeforeUnload)
- return () => window.removeEventListener('beforeunload', handleBeforeUnload)
- }, [isDirty])
-
- const utils = trpc.useUtils()
- const createCompetitionMutation = trpc.competition.create.useMutation()
- const createRoundMutation = trpc.round.create.useMutation()
- const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
-
- const handleStateChange = (updates: Partial) => {
- setState((prev) => ({ ...prev, ...updates }))
- setIsDirty(true)
-
- // Auto-generate slug from name if name changed
- if (updates.name !== undefined && updates.slug === undefined) {
- const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
- setState((prev) => ({ ...prev, slug: autoSlug }))
- }
- }
-
- const handleSubmit = async () => {
- if (!state.name.trim()) {
- toast.error('Competition name is required')
- setCurrentStep(0)
- return
- }
-
- if (!state.slug.trim()) {
- toast.error('Competition slug is required')
- setCurrentStep(0)
- return
- }
-
- if (state.rounds.length === 0) {
- toast.error('At least one round is required')
- setCurrentStep(1)
- return
- }
-
- try {
- // Create competition
- const competition = await createCompetitionMutation.mutateAsync({
- programId: state.programId,
- name: state.name,
- slug: state.slug,
- categoryMode: state.categoryMode,
- startupFinalistCount: state.startupFinalistCount,
- conceptFinalistCount: state.conceptFinalistCount,
- notifyOnRoundAdvance: state.notifyOnRoundAdvance,
- notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
- deadlineReminderDays: state.deadlineReminderDays,
- })
-
- // Create rounds
- for (const round of state.rounds) {
- await createRoundMutation.mutateAsync({
- competitionId: competition.id,
- name: round.name,
- slug: round.slug,
- roundType: round.roundType as any,
- sortOrder: round.sortOrder,
- configJson: round.configJson,
- })
- }
-
- // Create jury groups
- for (const group of state.juryGroups) {
- await createJuryGroupMutation.mutateAsync({
- competitionId: competition.id,
- name: group.name,
- slug: group.slug,
- defaultMaxAssignments: group.defaultMaxAssignments,
- defaultCapMode: group.defaultCapMode as any,
- sortOrder: group.sortOrder,
- })
- }
-
- toast.success('Competition created successfully')
- setIsDirty(false)
- utils.competition.list.invalidate()
- router.push(`/admin/competitions/${competition.id}` as Route)
- } catch (err: any) {
- toast.error(err.message || 'Failed to create competition')
- }
- }
-
- const steps: StepConfig[] = [
- {
- title: 'Basics',
- description: 'Name and settings',
- isValid: !!state.name && !!state.slug,
- },
- {
- title: 'Rounds',
- description: 'Configure rounds',
- isValid: state.rounds.length > 0,
- },
- {
- title: 'Jury Groups',
- description: 'Add jury groups',
- isValid: true, // Optional
- },
- {
- title: 'Review',
- description: 'Confirm and create',
- isValid: !!state.name && !!state.slug && state.rounds.length > 0,
- },
- ]
-
- const canSubmit = steps.every((s) => s.isValid)
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
New Competition
-
- Create a multi-round competition workflow
-
-
-
-
- {/* Wizard */}
-
-
- handleStateChange({ rounds })} />
- handleStateChange({ juryGroups })}
- />
-
-
-
- )
-}
diff --git a/src/app/(admin)/admin/competitions/page.tsx b/src/app/(admin)/admin/competitions/page.tsx
deleted file mode 100644
index 25d0dec..0000000
--- a/src/app/(admin)/admin/competitions/page.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-'use client'
-
-import Link from 'next/link'
-import type { Route } from 'next'
-import { trpc } from '@/lib/trpc/client'
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card'
-import { Skeleton } from '@/components/ui/skeleton'
-import {
- Plus,
- Medal,
- Calendar,
- Users,
- Layers,
- FileBox,
-} from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { formatDistanceToNow } from 'date-fns'
-import { useEdition } from '@/contexts/edition-context'
-
-const statusConfig = {
- DRAFT: {
- label: 'Draft',
- bgClass: 'bg-gray-100 text-gray-700',
- dotClass: 'bg-gray-500',
- },
- ACTIVE: {
- label: 'Active',
- bgClass: 'bg-emerald-100 text-emerald-700',
- dotClass: 'bg-emerald-500',
- },
- CLOSED: {
- label: 'Closed',
- bgClass: 'bg-blue-100 text-blue-700',
- dotClass: 'bg-blue-500',
- },
- ARCHIVED: {
- label: 'Archived',
- bgClass: 'bg-muted text-muted-foreground',
- dotClass: 'bg-muted-foreground',
- },
-} as const
-
-export default function CompetitionListPage() {
- const { currentEdition } = useEdition()
- const programId = currentEdition?.id
-
- const { data: competitions, isLoading } = trpc.competition.list.useQuery(
- { programId: programId! },
- { enabled: !!programId, refetchInterval: 30_000 }
- )
-
- if (!programId) {
- return (
-
-
-
-
Competitions
-
- Select an edition to view competitions
-
-
-
-
-
-
- No Edition Selected
-
- Select an edition from the sidebar to view its competitions
-
-
-
-
- )
- }
-
- return (
-
- {/* Header */}
-
-
-
Competitions
-
- Manage competitions for {currentEdition?.name}
-
-
-
-
-
- New Competition
-
-
-
-
- {/* Loading */}
- {isLoading && (
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
-
-
-
-
- ))}
-
- )}
-
- {/* Empty State */}
- {!isLoading && (!competitions || competitions.length === 0) && (
-
-
-
-
-
- No Competitions Yet
-
- Competitions organize your multi-round evaluation workflow with jury groups,
- submission windows, and scoring. Create your first competition to get started.
-
-
-
-
- Create Your First Competition
-
-
-
-
- )}
-
- {/* Competition Cards */}
- {competitions && competitions.length > 0 && (
-
- {competitions.map((competition) => {
- const status = competition.status as keyof typeof statusConfig
- const config = statusConfig[status] || statusConfig.DRAFT
-
- return (
-
-
-
-
-
-
- {competition.name}
-
-
- {competition.slug}
-
-
-
-
- {config.label}
-
-
-
-
-
-
-
-
- {competition._count.rounds} rounds
-
-
-
- {competition._count.juryGroups} juries
-
-
-
- {competition._count.submissionWindows} windows
-
-
-
- Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
-
-
-
-
- )
- })}
-
- )}
-
- )
-}
diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx
index 6d6adad..3fd3bdd 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, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -9,6 +10,17 @@ import {
AlertTriangle,
Upload,
UserPlus,
+ Settings,
+ ClipboardCheck,
+ Users,
+ Send,
+ FileDown,
+ Calendar,
+ Eye,
+ Presentation,
+ Vote,
+ Play,
+ Lock,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -29,6 +41,77 @@ type DashboardContentProps = {
sessionName: string
}
+type QuickAction = {
+ label: string
+ href: string
+ icon: React.ElementType
+}
+
+function getContextualActions(
+ activeRound: { id: string; roundType: string } | null
+): QuickAction[] {
+ if (!activeRound) {
+ return [
+ { label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
+ { label: 'Import', href: '/admin/projects/new', icon: Upload },
+ { label: 'Invite', href: '/admin/members', icon: UserPlus },
+ ]
+ }
+
+ const roundHref = `/admin/rounds/${activeRound.id}`
+
+ switch (activeRound.roundType) {
+ case 'INTAKE':
+ return [
+ { label: 'Import Projects', href: '/admin/projects/new', icon: Upload },
+ { label: 'Review', href: roundHref, icon: ClipboardCheck },
+ { label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
+ ]
+ case 'FILTERING':
+ return [
+ { label: 'Run Screening', href: roundHref, icon: ClipboardCheck },
+ { label: 'Review Results', href: `${roundHref}?tab=filtering`, icon: Eye },
+ { label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
+ ]
+ case 'EVALUATION':
+ return [
+ { label: 'Assignments', href: `${roundHref}?tab=assignments`, icon: Users },
+ { label: 'Send Reminders', href: `${roundHref}?tab=assignments`, icon: Send },
+ { label: 'Export', href: roundHref, icon: FileDown },
+ ]
+ case 'SUBMISSION':
+ return [
+ { label: 'Submissions', href: roundHref, icon: ClipboardCheck },
+ { label: 'Deadlines', href: `${roundHref}?tab=config`, icon: Calendar },
+ { label: 'Status', href: `${roundHref}?tab=projects`, icon: Eye },
+ ]
+ case 'MENTORING':
+ return [
+ { label: 'Mentors', href: `${roundHref}?tab=projects`, icon: Users },
+ { label: 'Progress', href: roundHref, icon: Eye },
+ { label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
+ ]
+ case 'LIVE_FINAL':
+ return [
+ { label: 'Live Control', href: roundHref, icon: Presentation },
+ { label: 'Results', href: `${roundHref}?tab=projects`, icon: Vote },
+ { label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
+ ]
+ case 'DELIBERATION':
+ return [
+ { label: 'Sessions', href: roundHref, icon: Play },
+ { label: 'Results', href: `${roundHref}?tab=projects`, icon: Eye },
+ { label: 'Lock Results', href: roundHref, icon: Lock },
+ ]
+ default:
+ return [
+ { label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
+ { label: 'Import', href: '/admin/projects/new', icon: Upload },
+ { label: 'Invite', href: '/admin/members', icon: UserPlus },
+ ]
+ }
+}
+
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId },
@@ -83,6 +166,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
evaluationStats,
totalAssignments,
latestProjects,
+ recentlyActiveProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
@@ -92,6 +176,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
: null
+ // Find next draft round for summary panel
+ const lastActiveSortOrder = Math.max(
+ ...pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE').map((r) => r.sortOrder),
+ -1
+ )
+ const nextDraftRound = pipelineRounds.find(
+ (r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
+ ) ?? null
+
+ const quickActions = getContextualActions(activeRound)
+
return (
<>
{/* Page Header */}
@@ -109,25 +204,15 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Welcome back, {sessionName}
-
-
-
-
- Rounds
-
-
-
-
-
- Import
-
-
-
-
-
- Invite
-
-
+
+ {quickActions.map((action) => (
+
+
+
+ {action.label}
+
+
+ ))}
@@ -147,6 +232,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
totalAssignments={totalAssignments}
evaluationStats={evaluationStats}
actionsCount={nextActions.length}
+ nextDraftRound={nextDraftRound ? { name: nextDraftRound.name, roundType: nextDraftRound.roundType } : null}
/>
@@ -161,7 +247,11 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
-
+
{recentEvals && recentEvals.length > 0 && (
diff --git a/src/app/(admin)/admin/juries/page.tsx b/src/app/(admin)/admin/juries/page.tsx
index b29875c..5dd787f 100644
--- a/src/app/(admin)/admin/juries/page.tsx
+++ b/src/app/(admin)/admin/juries/page.tsx
@@ -34,8 +34,8 @@ import {
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
-import { cn } from '@/lib/utils'
-import { Plus, Scale, Users, Loader2 } from 'lucide-react'
+import { cn, formatEnumLabel } from '@/lib/utils'
+import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
const capModeLabels = {
HARD: 'Hard Cap',
@@ -267,33 +267,82 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
No jury groups configured for this competition.
) : (
-
+
{juryGroups.map((group) => (
-
+
-
+
+ {/* Header row */}
-
{group.name}
-
- {capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
-
-
-
-
-
-
{group._count.members} members
+
+
+
+
+
+
+ {group.name}
+
+
+ {group._count.members} member{group._count.members !== 1 ? 's' : ''}
+ {' · '}
+ {group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
+
+
-
- {group._count.assignments} assignments
+
+
+ {capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
+
+
-
- Default max: {group.defaultMaxAssignments}
-
+
+ {/* Round assignments */}
+ {(group as any).rounds?.length > 0 && (
+
+ {(group as any).rounds.map((r: any) => (
+
+
+ {r.name}
+
+ ))}
+
+ )}
+
+ {/* Member preview */}
+ {(group as any).members?.length > 0 && (
+
+
+ {(group as any).members.slice(0, 5).map((m: any) => (
+
+ {(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
+
+ ))}
+
+ {group._count.members > 5 && (
+
+ +{group._count.members - 5} more
+
+ )}
+
+ )}
diff --git a/src/app/(admin)/admin/programs/[id]/page.tsx b/src/app/(admin)/admin/programs/[id]/page.tsx
index b6ebce1..7761b16 100644
--- a/src/app/(admin)/admin/programs/[id]/page.tsx
+++ b/src/app/(admin)/admin/programs/[id]/page.tsx
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
+import { CompetitionSettings } from '@/components/admin/program/competition-settings'
interface ProgramDetailPageProps {
params: Promise<{ id: string }>
@@ -84,6 +85,24 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
)}
+ {(() => {
+ const comp = (program as any).competitions?.[0]
+ if (!comp) return null
+ return (
+
+ )
+ })()}
+
@@ -93,9 +112,9 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
-
+
- Manage Competitions
+ Manage Rounds
diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx
index a26dd10..3739c61 100644
--- a/src/app/(admin)/admin/projects/page.tsx
+++ b/src/app/(admin)/admin/projects/page.tsx
@@ -637,7 +637,7 @@ export default function ProjectsPage() {
)}
-
+
Assign to Round
diff --git a/src/app/(admin)/admin/projects/pool/page.tsx b/src/app/(admin)/admin/projects/pool/page.tsx
deleted file mode 100644
index 3364514..0000000
--- a/src/app/(admin)/admin/projects/pool/page.tsx
+++ /dev/null
@@ -1,558 +0,0 @@
-'use client'
-
-import { useState, useEffect, useMemo } from 'react'
-import { useSearchParams } from 'next/navigation'
-import Link from 'next/link'
-import type { Route } from 'next'
-import { trpc } from '@/lib/trpc/client'
-import { useEdition } from '@/contexts/edition-context'
-import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Switch } from '@/components/ui/switch'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
- DialogDescription,
-} from '@/components/ui/dialog'
-import { Badge } from '@/components/ui/badge'
-import { Input } from '@/components/ui/input'
-import { Card, CardContent } from '@/components/ui/card'
-import { Skeleton } from '@/components/ui/skeleton'
-import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
-import { toast } from 'sonner'
-import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
-
-const roundTypeColors: Record = {
- INTAKE: 'bg-gray-100 text-gray-700',
- FILTERING: 'bg-amber-100 text-amber-700',
- EVALUATION: 'bg-blue-100 text-blue-700',
- SUBMISSION: 'bg-purple-100 text-purple-700',
- MENTORING: 'bg-teal-100 text-teal-700',
- LIVE_FINAL: 'bg-red-100 text-red-700',
- DELIBERATION: 'bg-indigo-100 text-indigo-700',
-}
-
-export default function ProjectPoolPage() {
- const searchParams = useSearchParams()
- const { currentEdition, isLoading: editionLoading } = useEdition()
-
- // URL params for deep-linking context
- const urlRoundId = searchParams.get('roundId') || ''
- const urlCompetitionId = searchParams.get('competitionId') || ''
-
- // Auto-select programId from edition
- const programId = currentEdition?.id || ''
-
- const [selectedProjects, setSelectedProjects] = useState([])
- const [assignDialogOpen, setAssignDialogOpen] = useState(false)
- const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
- const [targetRoundId, setTargetRoundId] = useState(urlRoundId)
- const [searchQuery, setSearchQuery] = useState('')
- const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
- const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
- const [currentPage, setCurrentPage] = useState(1)
- const perPage = 50
-
- // Pre-select target round from URL param
- useEffect(() => {
- if (urlRoundId) setTargetRoundId(urlRoundId)
- }, [urlRoundId])
-
- const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
- {
- programId,
- competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
- search: searchQuery || undefined,
- unassignedOnly: showUnassignedOnly,
- excludeRoundId: urlRoundId || undefined,
- page: currentPage,
- perPage,
- },
- { enabled: !!programId }
- )
-
- // Load rounds from program (flattened from all competitions, now with competitionId)
- const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
- { id: programId },
- { enabled: !!programId }
- )
-
- // Get round name for context banner
- const allRounds = useMemo(() => {
- return (programData?.rounds || []) as Array<{
- id: string
- name: string
- competitionId: string
- status: string
- _count: { projects: number; assignments: number }
- }>
- }, [programData])
-
- // Filter rounds by competitionId if URL param is set
- const filteredRounds = useMemo(() => {
- if (urlCompetitionId) {
- return allRounds.filter((r) => r.competitionId === urlCompetitionId)
- }
- return allRounds
- }, [allRounds, urlCompetitionId])
-
- const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
-
- const utils = trpc.useUtils()
-
- const assignMutation = trpc.projectPool.assignToRound.useMutation({
- onSuccess: (result) => {
- utils.project.list.invalidate()
- utils.projectPool.listUnassigned.invalidate()
- toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
- setSelectedProjects([])
- setAssignDialogOpen(false)
- setTargetRoundId(urlRoundId)
- refetch()
- },
- onError: (error: unknown) => {
- toast.error((error as { message?: string }).message || 'Failed to assign projects')
- },
- })
-
- const assignAllMutation = trpc.projectPool.assignAllToRound.useMutation({
- onSuccess: (result) => {
- utils.project.list.invalidate()
- utils.projectPool.listUnassigned.invalidate()
- toast.success(`Assigned all ${result.assignedCount} projects to round`)
- setSelectedProjects([])
- setAssignAllDialogOpen(false)
- setTargetRoundId(urlRoundId)
- refetch()
- },
- onError: (error: unknown) => {
- toast.error((error as { message?: string }).message || 'Failed to assign projects')
- },
- })
-
- const isPending = assignMutation.isPending || assignAllMutation.isPending
-
- const handleBulkAssign = () => {
- if (selectedProjects.length === 0 || !targetRoundId) return
- assignMutation.mutate({
- projectIds: selectedProjects,
- roundId: targetRoundId,
- })
- }
-
- const handleAssignAll = () => {
- if (!targetRoundId || !programId) return
- assignAllMutation.mutate({
- programId,
- roundId: targetRoundId,
- competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
- unassignedOnly: showUnassignedOnly,
- })
- }
-
- const handleQuickAssign = (projectId: string, roundId: string) => {
- assignMutation.mutate({
- projectIds: [projectId],
- roundId,
- })
- }
-
- const toggleSelectAll = () => {
- if (!poolData?.projects) return
- if (selectedProjects.length === poolData.projects.length) {
- setSelectedProjects([])
- } else {
- setSelectedProjects(poolData.projects.map((p) => p.id))
- }
- }
-
- const toggleSelectProject = (projectId: string) => {
- if (selectedProjects.includes(projectId)) {
- setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
- } else {
- setSelectedProjects([...selectedProjects, projectId])
- }
- }
-
- if (editionLoading) {
- return (
-
-
-
-
-
- )
- }
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
Project Pool
-
- {currentEdition
- ? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
- : 'No edition selected'}
-
-
-
-
- {/* Context banner when coming from a round */}
- {contextRound && (
-
-
-
-
-
-
- Assigning to {contextRound.name}
- {' \u2014 '}
-
- projects already in this round are hidden
-
-
-
-
-
-
- Back to Round
-
-
-
-
-
- )}
-
- {/* Filters */}
-
-
-
- Category
- {
- setCategoryFilter(value as 'STARTUP' | 'BUSINESS_CONCEPT' | 'all')
- setCurrentPage(1)
- }}>
-
-
-
-
- All Categories
- Startup
- Business Concept
-
-
-
-
-
- Search
- {
- setSearchQuery(e.target.value)
- setCurrentPage(1)
- }}
- />
-
-
-
- {
- setShowUnassignedOnly(checked)
- setCurrentPage(1)
- }}
- />
-
- Unassigned only
-
-
-
-
-
- {/* Action bar */}
- {programId && poolData && poolData.total > 0 && (
-
-
- {poolData.total} project{poolData.total !== 1 ? 's' : ''}
- {showUnassignedOnly && ' (unassigned only)'}
-
-
- {selectedProjects.length > 0 && (
- setAssignDialogOpen(true)} size="sm">
- Assign {selectedProjects.length} Selected
-
- )}
- setAssignAllDialogOpen(true)}
- >
- Assign All {poolData.total} to Round
-
-
-
- )}
-
- {/* Projects Table */}
- {programId ? (
- <>
- {isLoadingPool ? (
-
-
- {[...Array(5)].map((_, i) => (
-
- ))}
-
-
- ) : poolData && poolData.total > 0 ? (
- <>
-
-
-
-
-
-
- 0 && selectedProjects.length === poolData.projects.length}
- onCheckedChange={toggleSelectAll}
- />
-
- Project
- Category
- Rounds
- Country
- Submitted
- Quick Assign
-
-
-
- {poolData.projects.map((project) => (
-
-
- toggleSelectProject(project.id)}
- />
-
-
-
- {project.title}
- {project.teamName}
-
-
-
- {project.competitionCategory && (
-
- {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
-
- )}
-
-
- {(project as any).projectRoundStates?.length > 0 ? (
-
- {(project as any).projectRoundStates.map((prs: any) => (
-
- {prs.round?.name || 'Round'}
-
- ))}
-
- ) : (
- None
- )}
-
-
- {project.country ? (() => {
- const code = normalizeCountryToCode(project.country)
- const flag = code ? getCountryFlag(code) : null
- const name = code ? getCountryName(code) : project.country
- return <>{flag && {flag} }{name}>
- })() : '-'}
-
-
- {project.submittedAt
- ? new Date(project.submittedAt).toLocaleDateString()
- : '-'}
-
-
- {isLoadingRounds ? (
-
- ) : (
- handleQuickAssign(project.id, roundId)}
- disabled={isPending}
- >
-
-
-
-
- {filteredRounds.map((round) => (
-
- {round.name}
-
- ))}
-
-
- )}
-
-
- ))}
-
-
-
-
-
- {/* Pagination */}
- {poolData.totalPages > 1 && (
-
-
- Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total}
-
-
- setCurrentPage(currentPage - 1)}
- disabled={currentPage === 1}
- >
-
- Previous
-
- setCurrentPage(currentPage + 1)}
- disabled={currentPage === poolData.totalPages}
- >
- Next
-
-
-
-
- )}
- >
- ) : (
-
-
-
-
- {showUnassignedOnly
- ? 'No unassigned projects found'
- : urlRoundId
- ? 'All projects are already assigned to this round'
- : 'No projects found for this program'}
-
-
-
- )}
- >
- ) : (
-
- No edition selected. Please select an edition from the sidebar.
-
- )}
-
- {/* Bulk Assignment Dialog (selected projects) */}
-
-
-
- Assign Selected Projects
-
- Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to a round:
-
-
-
-
-
-
-
-
- {filteredRounds.map((round) => (
-
- {round.name}
-
- ))}
-
-
-
-
- setAssignDialogOpen(false)}>
- Cancel
-
-
- {assignMutation.isPending && }
- Assign {selectedProjects.length} Projects
-
-
-
-
-
- {/* Assign ALL Dialog */}
-
-
-
- Assign All Projects
-
- This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
-
-
-
-
-
-
-
-
- {filteredRounds.map((round) => (
-
- {round.name}
-
- ))}
-
-
-
-
- setAssignAllDialogOpen(false)}>
- Cancel
-
-
- {assignAllMutation.isPending && }
- Assign All {poolData?.total || 0} Projects
-
-
-
-
-
- )
-}
diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
index 06b397c..d0ffe90 100644
--- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
-import { useParams } from 'next/navigation'
+import { useParams, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -12,7 +12,6 @@ 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'
@@ -68,43 +67,13 @@ import {
UserPlus,
CheckCircle2,
AlertTriangle,
- FileText,
Trophy,
- Clock,
- Send,
Download,
Plus,
Trash2,
ArrowRight,
RotateCcw,
- X,
- Check,
- ChevronsUpDown,
- Search,
- MoreHorizontal,
- ShieldAlert,
- Eye,
- Pencil,
- Mail,
- History,
- ChevronRight,
- ArrowRightLeft,
- Sparkles,
} from 'lucide-react'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover'
-import { ScrollArea } from '@/components/ui/scroll-area'
import {
Tooltip,
TooltipContent,
@@ -118,13 +87,29 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen
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'
import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
import { motion } from 'motion/react'
-import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
-import type { Criterion } from '@/components/forms/evaluation-form-builder'
+import {
+ roundTypeConfig as sharedRoundTypeConfig,
+ roundStatusConfig as sharedRoundStatusConfig,
+ projectStateConfig,
+} from '@/lib/round-config'
+import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
+import { RoundUnassignedQueue } from '@/components/admin/assignment/round-unassigned-queue'
+import { JuryProgressTable } from '@/components/admin/assignment/jury-progress-table'
+import { ReassignmentHistory } from '@/components/admin/assignment/reassignment-history'
+import { ScoreDistribution } from '@/components/admin/round/score-distribution'
+import { SendRemindersButton } from '@/components/admin/assignment/send-reminders-button'
+import { NotifyJurorsButton } from '@/components/admin/assignment/notify-jurors-button'
+import { ExportEvaluationsDialog } from '@/components/admin/round/export-evaluations-dialog'
+import { IndividualAssignmentsTable } from '@/components/admin/assignment/individual-assignments-table'
+import { AdvanceProjectsDialog } from '@/components/admin/round/advance-projects-dialog'
+import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommendations-display'
+import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
+import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
+import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -140,52 +125,12 @@ function getRelativeTime(date: Date): string {
return diffMs > 0 ? `in ${label}` : `${label} ago`
}
-// ── 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 = {
- 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 = {
- 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',
-}
+// ── Status & type config maps (from shared lib) ────────────────────────────
+const roundStatusConfig = sharedRoundStatusConfig
+const roundTypeConfig = sharedRoundTypeConfig
+const stateColors: Record = Object.fromEntries(
+ Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
+)
// ═══════════════════════════════════════════════════════════════════════════
// Main Page Component
@@ -194,6 +139,8 @@ const stateColors: Record = {
export default function RoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
+ const searchParams = useSearchParams()
+ const backUrl = searchParams.get('from')
const [config, setConfig] = useState>({})
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
@@ -487,7 +434,7 @@ export default function RoundDetailPage() {
const hasAwards = hasJury
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
- const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
+ const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
// ── Loading state ──────────────────────────────────────────────────────
if (isLoading) {
@@ -592,7 +539,7 @@ export default function RoundDetailPage() {
>
-
+
{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}
@@ -663,13 +610,36 @@ export default function RoundDetailPage() {
{status === 'ROUND_DRAFT' && (
- setStatusConfirmAction('activate')}
- disabled={isTransitioning}
- >
-
- Activate Round
-
+
+
+
+
+ setStatusConfirmAction('activate')}
+ disabled={isTransitioning || readyCount < readinessItems.length}
+ >
+
+ Activate Round
+ {readyCount < readinessItems.length && (
+
+ {readyCount}/{readinessItems.length}
+
+ )}
+
+
+
+ {readyCount < readinessItems.length && (
+
+ Not ready to activate:
+
+ {readinessItems.filter((i) => !i.ready).map((item) => (
+ • {item.label}
+ ))}
+
+
+ )}
+
+
)}
{status === 'ROUND_ACTIVE' && (
- {statusConfirmAction === 'activate' && 'The round will go live. Projects can be processed and jury members will be able to see their assignments.'}
+ {statusConfirmAction === 'activate' && (
+ <>
+ The round will go live. Projects can be processed and jury members will be able to see their assignments.
+ {readyCount < readinessItems.length && (
+
+ Warning: {readinessItems.length - readyCount} readiness item(s) not yet complete ({readinessItems.filter((i) => !i.ready).map((i) => i.label).join(', ')}).
+
+ )}
+ >
+ )}
{statusConfirmAction === 'close' && 'No further changes will be accepted. You can reactivate later if needed.'}
{statusConfirmAction === 'reopen' && 'The round will become active again. Any rounds after this one that are currently active will be paused automatically.'}
{statusConfirmAction === 'archive' && 'The round will be archived. It will only be available as a historical record.'}
@@ -817,7 +796,7 @@ export default function RoundDetailPage() {
setActiveTab('jury')}
+ onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Change
@@ -829,7 +808,7 @@ export default function RoundDetailPage() {
setActiveTab('jury')}
+ onClick={() => setActiveTab(isEvaluation ? 'assignments' : 'jury')}
>
Assign jury group
@@ -924,8 +903,8 @@ export default function RoundDetailPage() {
{ value: 'overview', label: 'Overview', icon: Zap },
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
- ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
- ...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
+ ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
+ ...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
@@ -1445,7 +1424,7 @@ export default function RoundDetailPage() {
{[
- { label: 'Type', value: {typeCfg.label} },
+ { label: 'Type', value: {typeCfg.label} },
{ label: 'Status', value: {statusCfg.label} },
{ label: 'Position', value: {`Round ${(round.sortOrder ?? 0) + 1}${competition?.rounds ? ` of ${competition.rounds.length}` : ''}`} },
...(round.purposeKey ? [{ label: 'Purpose', value: {round.purposeKey} }] : []),
@@ -1518,8 +1497,8 @@ export default function RoundDetailPage() {
)}
- {/* ═══════════ JURY TAB ═══════════ */}
- {hasJury && (
+ {/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
+ {hasJury && !isEvaluation && (
{/* Jury Group Selector + Create */}
@@ -1774,27 +1753,203 @@ export default function RoundDetailPage() {
)}
- {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
+ {/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds — includes Jury section) ═══════════ */}
{isEvaluation && (
- {/* 4.9 Gate assignments when no jury group */}
- {!round?.juryGroupId ? (
-
-
-
-
+ {/* ── Jury Group Selector (merged from Jury tab for EVALUATION) ── */}
+
+
+
+
+ Jury Group
+
+ Select or create a jury group for this round
+
-
No Jury Group Assigned
-
- Assign a jury group first to manage assignments.
-
-
setActiveTab('jury')}>
-
- Go to Jury Tab
-
+
+
setCreateJuryOpen(true)}>
+
+ New Jury
+
+
+
+
+
+ {juryGroups && juryGroups.length > 0 ? (
+
+
{
+ assignJuryMutation.mutate({
+ id: roundId,
+ juryGroupId: value === '__none__' ? null : value,
+ })
+ }}
+ disabled={assignJuryMutation.isPending}
+ >
+
+
+
+
+ No jury assigned
+ {juryGroups.map((jg: any) => (
+
+ {jg.name} ({jg._count?.members ?? 0} members)
+
+ ))}
+
+
+
+ {/* Delete button for currently selected jury group */}
+ {round.juryGroupId && (
+
+
+
+
+ Delete "{juryGroup?.name}"
+
+
+
+
+ Delete jury group?
+
+ This will permanently delete "{juryGroup?.name}" and remove all its members.
+ Rounds using this jury group will be unlinked. This action cannot be undone.
+
+
+
+ Cancel
+ deleteJuryMutation.mutate({ id: round.juryGroupId! })}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={deleteJuryMutation.isPending}
+ >
+ {deleteJuryMutation.isPending && }
+ Delete Jury
+
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+
No Jury Groups
+
+ Create a jury group to assign members who will evaluate projects in this round.
+
+
setCreateJuryOpen(true)}>
+
+ Create First Jury
+
+
+ )}
+
+
+
+ {/* ── Members list (only if a jury group is assigned) ── */}
+ {juryGroupDetail && (
+
+
+
+
+
+ Members — {juryGroupDetail.name}
+
+
+ {juryGroupDetail.members.length} member{juryGroupDetail.members.length !== 1 ? 's' : ''}
+
+
+
setAddMemberOpen(true)}>
+
+ Add Member
+
+
+
+
+ {juryGroupDetail.members.length === 0 ? (
+
+
+
+
+
No Members Yet
+
+ Add jury members to start assigning projects for evaluation.
+
+
setAddMemberOpen(true)}>
+
+ Add First Member
+
+
+ ) : (
+
+ {juryGroupDetail.members.map((member: any, idx: number) => (
+
+
+
+ {member.user.name || 'Unnamed User'}
+
+
{member.user.email}
+
+
+
updateJuryMemberMutation.mutate({
+ id: member.id,
+ maxAssignmentsOverride: val,
+ })}
+ />
+
+
+
+
+
+
+
+
+ Remove member?
+
+ Remove {member.user.name || member.user.email} from {juryGroupDetail.name}?
+
+
+
+ Cancel
+ removeJuryMemberMutation.mutate({ id: member.id })}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Remove
+
+
+
+
+
+
+ ))}
+
+ )}
- ) : (
+ )}
+
+ {/* ── Assignments content (only shown when jury group is assigned) ── */}
+ {round?.juryGroupId && (
<>
{/* Card 1: Coverage & Generation */}
@@ -1991,12 +2146,23 @@ export default function RoundDetailPage() {
{/* Round Dates */}
- Round Dates
-
- When this round starts and ends. Defines the active period for document uploads and evaluations.
-
+
+ {!round.windowOpenAt && !round.windowCloseAt && (
+
+
Dates not set — this round cannot be activated without dates.
+
+ )}
Start Date
@@ -2023,8 +2189,20 @@ export default function RoundDetailPage() {
{/* General Round Settings */}
- General Settings
- Settings that apply to this round regardless of type
+
@@ -2065,6 +2243,11 @@ export default function RoundDetailPage() {
Advancement Targets
+ {isEvaluation && !(config.startupAdvanceCount as number) && !(config.conceptAdvanceCount as number) && (
+
+
Advancement targets not configured — all passed projects will be eligible to advance.
+
+ )}
Target number of projects per category to advance from this round
@@ -2123,13 +2306,19 @@ export default function RoundDetailPage() {
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (
- Document Requirements
-
- Files applicants must submit for this round
- {round.windowCloseAt && (
- <> — due by {new Date(round.windowCloseAt).toLocaleDateString()}>
- )}
-
+ 0 ? 'complete' : 'warning'}
+ summary={
+ (fileRequirements?.length ?? 0) > 0
+ ? `${fileRequirements?.length} requirement(s)`
+ : undefined
+ }
+ />
)
}
-
-// ═══════════════════════════════════════════════════════════════════════════
-// Sub-components
-// ═══════════════════════════════════════════════════════════════════════════
-
-// ── Inline cap editor for jury members on round page ─────────────────────
-
-function InlineMemberCap({
- memberId,
- currentValue,
- onSave,
- roundId,
- jurorUserId,
-}: {
- memberId: string
- currentValue: number | null
- onSave: (val: number | null) => void
- roundId?: string
- jurorUserId?: string
-}) {
- const utils = trpc.useUtils()
- const [editing, setEditing] = useState(false)
- const [value, setValue] = useState(currentValue?.toString() ?? '')
- const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
- const [showBanner, setShowBanner] = useState(false)
- const inputRef = useRef(null)
-
- const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
- onSuccess: (data) => {
- utils.assignment.listByStage.invalidate()
- utils.analytics.getJurorWorkload.invalidate()
- utils.roundAssignment.unassignedQueue.invalidate()
- setShowBanner(false)
- setOverCapInfo(null)
- if (data.failed > 0) {
- toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
- } else {
- toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
- }
- },
- onError: (err) => toast.error(err.message),
- })
-
- useEffect(() => {
- if (editing) inputRef.current?.focus()
- }, [editing])
-
- const save = async () => {
- const trimmed = value.trim()
- const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
- if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
- toast.error('Enter a positive number or leave empty for no cap')
- return
- }
- if (newVal === currentValue) {
- setEditing(false)
- return
- }
-
- // Check over-cap impact before saving
- if (newVal !== null && roundId && jurorUserId) {
- try {
- const preview = await utils.client.assignment.getOverCapPreview.query({
- roundId,
- jurorId: jurorUserId,
- newCap: newVal,
- })
- if (preview.overCapCount > 0) {
- setOverCapInfo(preview)
- setShowBanner(true)
- setEditing(false)
- return
- }
- } catch {
- // If preview fails, just save the cap normally
- }
- }
-
- onSave(newVal)
- setEditing(false)
- }
-
- const handleRedistribute = () => {
- const newVal = parseInt(value.trim(), 10)
- onSave(newVal)
- if (roundId && jurorUserId) {
- redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
- }
- }
-
- const handleJustSave = () => {
- const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
- onSave(newVal)
- setShowBanner(false)
- setOverCapInfo(null)
- }
-
- if (showBanner && overCapInfo) {
- return (
-
-
-
New cap of {value} is below current load ({overCapInfo.total} assignments). {overCapInfo.movableOverCap} can be redistributed.
- {overCapInfo.immovableOverCap > 0 && (
-
{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.
- )}
-
-
- {redistributeMutation.isPending ? : null}
- Redistribute
-
-
- Just save cap
-
- { setShowBanner(false); setOverCapInfo(null) }}>
- Cancel
-
-
-
-
- )
- }
-
- if (editing) {
- return (
- setValue(e.target.value)}
- onBlur={save}
- onKeyDown={(e) => {
- if (e.key === 'Enter') save()
- if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
- }}
- />
- )
- }
-
- return (
- { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
- >
- max:
- {currentValue ?? '\u221E'}
-
-
- )
-}
-
-// ── Unassigned projects queue ────────────────────────────────────────────
-
-function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) {
- const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
- { roundId, requiredReviews },
- { refetchInterval: 15_000 },
- )
-
- return (
-
-
-
Unassigned Projects
-
Projects with fewer than {requiredReviews} 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} / {requiredReviews}
-
-
- ))}
-
- ) : (
-
- All projects have sufficient assignments
-
- )}
-
-
- )
-}
-
-// ── Jury Progress Table ──────────────────────────────────────────────────
-
-function JuryProgressTable({ roundId }: { roundId: string }) {
- const utils = trpc.useUtils()
- const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
-
- const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
- { roundId },
- { refetchInterval: 15_000 },
- )
-
- const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
- onSuccess: (data) => {
- toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
- },
- onError: (err) => toast.error(err.message),
- })
-
- const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
- onSuccess: (data) => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.roundEngine.getProjectStates.invalidate({ roundId })
- utils.analytics.getJurorWorkload.invalidate({ roundId })
- utils.roundAssignment.unassignedQueue.invalidate({ roundId })
-
- if (data.failedCount > 0) {
- toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
- } else {
- toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
- }
- },
- onError: (err) => toast.error(err.message),
- })
-
- return (
- <>
-
-
- Jury Progress
- Evaluation completion per juror. Click the mail icon to notify an individual juror.
-
-
- {isLoading ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : !workload || workload.length === 0 ? (
-
- No assignments yet
-
- ) : (
-
- {workload.map((juror) => {
- const pct = juror.completionRate
- const barGradient = pct === 100
- ? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
- : pct >= 50
- ? 'bg-gradient-to-r from-blue-400 to-blue-600'
- : pct > 0
- ? 'bg-gradient-to-r from-amber-400 to-amber-600'
- : 'bg-gray-300'
-
- return (
-
-
-
{juror.name}
-
-
- {juror.completed}/{juror.assigned} ({pct}%)
-
-
-
-
- notifyMutation.mutate({ roundId, userId: juror.id })}
- >
- {notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
-
- ) : (
-
- )}
-
-
- Notify this juror of their assignments
-
-
-
-
-
-
- setTransferJuror({ id: juror.id, name: juror.name })}
- >
-
-
-
- Transfer assignments to other jurors
-
-
-
-
-
-
- {
- const ok = window.confirm(
- `Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
- )
- if (!ok) return
- reshuffleMutation.mutate({ roundId, jurorId: juror.id })
- }}
- >
- {reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
-
- ) : (
-
- )}
-
-
- Drop juror + reshuffle pending projects
-
-
-
-
-
-
- )
- })}
-
- )}
-
-
-
- {transferJuror && (
- setTransferJuror(null)}
- />
- )}
- >
- )
-}
-
-// ── Transfer Assignments Dialog ──────────────────────────────────────────
-
-function TransferAssignmentsDialog({
- roundId,
- sourceJuror,
- open,
- onClose,
-}: {
- roundId: string
- sourceJuror: { id: string; name: string }
- open: boolean
- onClose: () => void
-}) {
- const utils = trpc.useUtils()
- const [step, setStep] = useState<1 | 2>(1)
- const [selectedIds, setSelectedIds] = useState>(new Set())
-
- // Fetch source juror's assignments
- const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
- { roundId },
- { enabled: open },
- )
-
- const jurorAssignments = useMemo(() =>
- (sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
- [sourceAssignments, sourceJuror.id],
- )
-
- // Fetch transfer candidates when in step 2
- const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
- { roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
- { enabled: step === 2 && selectedIds.size > 0 },
- )
-
- // Per-assignment destination overrides
- const [destOverrides, setDestOverrides] = useState>({})
- const [forceOverCap, setForceOverCap] = useState(false)
-
- // Auto-assign: distribute assignments across eligible candidates balanced by load
- const handleAutoAssign = () => {
- if (!candidateData) return
- const movable = candidateData.assignments.filter((a) => a.movable)
- if (movable.length === 0) return
-
- // Simulate load starting from each candidate's current load
- const simLoad = new Map()
- for (const c of candidateData.candidates) {
- simLoad.set(c.userId, c.currentLoad)
- }
-
- const overrides: Record = {}
-
- for (const assignment of movable) {
- const eligible = candidateData.candidates
- .filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
-
- if (eligible.length === 0) continue
-
- // Sort: prefer not-all-completed, then under cap, then lowest simulated load
- const sorted = [...eligible].sort((a, b) => {
- // Prefer jurors who haven't completed all evaluations
- if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
- const loadA = simLoad.get(a.userId) ?? 0
- const loadB = simLoad.get(b.userId) ?? 0
- // Prefer jurors under their cap
- const overCapA = loadA >= a.cap ? 1 : 0
- const overCapB = loadB >= b.cap ? 1 : 0
- if (overCapA !== overCapB) return overCapA - overCapB
- // Then pick the least loaded
- return loadA - loadB
- })
-
- const best = sorted[0]
- overrides[assignment.id] = best.userId
- simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
- }
-
- setDestOverrides(overrides)
- }
-
- const transferMutation = trpc.assignment.transferAssignments.useMutation({
- onSuccess: (data) => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.analytics.getJurorWorkload.invalidate({ roundId })
- utils.roundAssignment.unassignedQueue.invalidate({ roundId })
- utils.assignment.getReassignmentHistory.invalidate({ roundId })
-
- const successCount = data.succeeded.length
- const failCount = data.failed.length
- if (failCount > 0) {
- toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
- } else {
- toast.success(`Transferred ${successCount} project(s) successfully.`)
- }
- onClose()
- },
- onError: (err) => toast.error(err.message),
- })
-
- // Build the transfer plan: for each selected assignment, determine destination
- const transferPlan = useMemo(() => {
- if (!candidateData) return []
- const movable = candidateData.assignments.filter((a) => a.movable)
- return movable.map((assignment) => {
- const override = destOverrides[assignment.id]
- // Default: first eligible candidate
- const defaultDest = candidateData.candidates.find((c) =>
- c.eligibleProjectIds.includes(assignment.projectId)
- )
- const destId = override || defaultDest?.userId || ''
- const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
- return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
- }).filter((t) => t.destinationJurorId)
- }, [candidateData, destOverrides])
-
- // Check if any destination is at or over cap
- const anyOverCap = useMemo(() => {
- if (!candidateData) return false
- const destCounts = new Map()
- for (const t of transferPlan) {
- destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
- }
- return candidateData.candidates.some((c) => {
- const extraLoad = destCounts.get(c.userId) ?? 0
- return c.currentLoad + extraLoad > c.cap
- })
- }, [candidateData, transferPlan])
-
- const handleTransfer = () => {
- transferMutation.mutate({
- roundId,
- sourceJurorId: sourceJuror.id,
- transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
- forceOverCap,
- })
- }
-
- const isMovable = (a: any) => {
- const status = a.evaluation?.status
- return !status || status === 'NOT_STARTED' || status === 'DRAFT'
- }
-
- const movableAssignments = jurorAssignments.filter(isMovable)
- const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
-
- return (
- { if (!v) onClose() }}>
-
-
- Transfer Assignments from {sourceJuror.name}
-
- {step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
-
-
-
- {step === 1 && (
-
- {loadingAssignments ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : jurorAssignments.length === 0 ? (
-
No assignments found.
- ) : (
- <>
-
- {
- if (checked) {
- setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
- } else {
- setSelectedIds(new Set())
- }
- }}
- />
- Select all movable ({movableAssignments.length})
-
-
- {jurorAssignments.map((a: any) => {
- const movable = isMovable(a)
- const status = a.evaluation?.status || 'No evaluation'
- return (
-
-
{
- const next = new Set(selectedIds)
- if (checked) next.add(a.id)
- else next.delete(a.id)
- setSelectedIds(next)
- }}
- />
-
-
{a.project?.title || 'Unknown'}
-
-
- {status}
-
-
- )
- })}
-
- >
- )}
-
- Cancel
- { setStep(2); setDestOverrides({}) }}
- >
- Next ({selectedIds.size} selected)
-
-
-
- )}
-
- {step === 2 && (
-
- {loadingCandidates ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : !candidateData || candidateData.candidates.length === 0 ? (
-
No eligible candidates found.
- ) : (
- <>
-
-
-
- Auto-assign
-
-
-
- {candidateData.assignments.filter((a) => a.movable).map((assignment) => {
- const currentDest = destOverrides[assignment.id] ||
- candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
- return (
-
-
-
{assignment.projectTitle}
-
{assignment.evalStatus || 'No evaluation'}
-
-
setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
- >
-
-
-
-
- {candidateData.candidates
- .filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
- .map((c) => (
-
- {c.name}
- ({c.currentLoad}/{c.cap})
- {c.allCompleted && Done }
-
- ))}
-
-
-
- )
- })}
-
-
- {transferPlan.length > 0 && (
-
- Transfer {transferPlan.length} project(s) from {sourceJuror.name}
-
- )}
-
- {anyOverCap && (
-
- setForceOverCap(!!checked)}
- />
- Force over-cap: some destinations will exceed their assignment limit
-
- )}
- >
- )}
-
- setStep(1)}>Back
-
- {transferMutation.isPending ? : null}
- Transfer {transferPlan.length} project(s)
-
-
-
- )}
-
-
- )
-}
-
-// ── Reassignment History ─────────────────────────────────────────────────
-
-function ReassignmentHistory({ roundId }: { roundId: string }) {
- const [expanded, setExpanded] = useState(false)
- const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
- { roundId },
- { enabled: expanded },
- )
-
- return (
-
- setExpanded(!expanded)}
- >
-
-
- Reassignment History
-
-
- Juror dropout, COI, transfer, and cap redistribution audit trail
-
- {expanded && (
-
- {isLoading ? (
-
- {[1, 2].map((i) => )}
-
- ) : !events || events.length === 0 ? (
-
- No reassignment events for this round
-
- ) : (
-
- {events.map((event) => (
-
-
-
-
- {event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
-
-
- {event.droppedJuror.name}
-
-
-
- {new Date(event.timestamp).toLocaleString()}
-
-
-
-
- By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
- {event.failedCount > 0 && `, ${event.failedCount} failed`}
-
-
- {event.moves.length > 0 && (
-
-
-
-
- Project
- Reassigned To
-
-
-
- {event.moves.map((move, i) => (
-
-
- {move.projectTitle}
-
-
- {move.newJurorName}
-
-
- ))}
-
-
-
- )}
-
- {event.failedProjects.length > 0 && (
-
-
Could not reassign:
-
- {event.failedProjects.map((p, i) => (
- {p}
- ))}
-
-
- )}
-
- ))}
-
- )}
-
- )}
-
- )
-}
-
-// ── Score Distribution ───────────────────────────────────────────────────
-
-function ScoreDistribution({ roundId }: { roundId: string }) {
- const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery(
- { roundId },
- { refetchInterval: 15_000 },
- )
-
- const maxCount = useMemo(() =>
- dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
- [dist])
-
- return (
-
-
- Score Distribution
-
- {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
-
-
-
- {isLoading ? (
-
- {Array.from({ length: 10 }).map((_, i) => )}
-
- ) : !dist || dist.totalEvaluations === 0 ? (
-
- No evaluations submitted yet
-
- ) : (
-
- {dist.globalDistribution.map((bucket) => {
- const heightPct = (bucket.count / maxCount) * 100
- return (
-
-
{bucket.count || ''}
-
-
{bucket.score}
-
- )
- })}
-
- )}
-
-
- )
-}
-
-// ── 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 (
- <>
- setOpen(true)}>
-
- Send Reminders
-
-
-
-
- Send evaluation reminders?
-
- This will send reminder emails to all jurors who have incomplete evaluations for this round.
-
-
-
- Cancel
- mutation.mutate({ roundId })}
- disabled={mutation.isPending}
- >
- {mutation.isPending && }
- Send Reminders
-
-
-
-
- >
- )
-}
-
-// ── Notify Jurors of Assignments Button ──────────────────────────────────
-
-function NotifyJurorsButton({ roundId }: { roundId: string }) {
- const [open, setOpen] = useState(false)
- const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
- onSuccess: (data) => {
- toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
- setOpen(false)
- },
- onError: (err) => toast.error(err.message),
- })
-
- return (
- <>
- setOpen(true)}>
-
- Notify Jurors
-
-
-
-
- Notify jurors of their assignments?
-
- This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
-
-
-
- Cancel
- mutation.mutate({ roundId })}
- disabled={mutation.isPending}
- >
- {mutation.isPending && }
- Notify Jurors
-
-
-
-
- >
- )
-}
-
-// ── Export Evaluations Dialog ─────────────────────────────────────────────
-
-function ExportEvaluationsDialog({
- roundId,
- open,
- onOpenChange,
-}: {
- roundId: string
- open: boolean
- onOpenChange: (open: boolean) => void
-}) {
- const [exportData, setExportData] = useState(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 (
-
- )
-}
-
-// ── Individual Assignments Table ─────────────────────────────────────────
-
-function IndividualAssignmentsTable({
- roundId,
- projectStates,
-}: {
- roundId: string
- projectStates: any[] | undefined
-}) {
- const [addDialogOpen, setAddDialogOpen] = useState(false)
- const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
- const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
- // ── By Juror mode state ──
- const [selectedJurorId, setSelectedJurorId] = useState('')
- const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set())
- const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
- const [projectSearch, setProjectSearch] = useState('')
- // ── By Project mode state ──
- const [selectedProjectId, setSelectedProjectId] = useState('')
- const [selectedJurorIds, setSelectedJurorIds] = useState>(new Set())
- const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
- const [jurorSearch, setJurorSearch] = useState('')
-
- const utils = trpc.useUtils()
- const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
- { roundId },
- { refetchInterval: 15_000 },
- )
-
- const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
- { roundId },
- { enabled: addDialogOpen },
- )
-
- const deleteMutation = trpc.assignment.delete.useMutation({
- onSuccess: () => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.roundEngine.getProjectStates.invalidate({ roundId })
- toast.success('Assignment removed')
- },
- onError: (err) => toast.error(err.message),
- })
-
- const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({
- onSuccess: () => {
- utils.assignment.listByStage.invalidate({ roundId })
- toast.success('Evaluation reset — juror can now start over')
- },
- onError: (err) => toast.error(err.message),
- })
-
- const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
- onSuccess: (data) => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.roundEngine.getProjectStates.invalidate({ roundId })
- utils.analytics.getJurorWorkload.invalidate({ roundId })
- utils.evaluation.listCOIByStage.invalidate({ roundId })
- toast.success(`Reassigned to ${data.newJurorName}`)
- },
- onError: (err) => toast.error(err.message),
- })
-
- const createMutation = trpc.assignment.create.useMutation({
- onSuccess: () => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.roundEngine.getProjectStates.invalidate({ roundId })
- utils.user.getJuryMembers.invalidate({ roundId })
- toast.success('Assignment created')
- resetDialog()
- },
- onError: (err) => toast.error(err.message),
- })
-
- const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
- onSuccess: (result) => {
- utils.assignment.listByStage.invalidate({ roundId })
- utils.roundEngine.getProjectStates.invalidate({ roundId })
- utils.user.getJuryMembers.invalidate({ roundId })
- toast.success(`${result.created} assignment(s) created`)
- resetDialog()
- },
- onError: (err) => toast.error(err.message),
- })
-
- const resetDialog = useCallback(() => {
- setAddDialogOpen(false)
- setAssignMode('byJuror')
- setSelectedJurorId('')
- setSelectedProjectIds(new Set())
- setProjectSearch('')
- setSelectedProjectId('')
- setSelectedJurorIds(new Set())
- setJurorSearch('')
- }, [])
-
- const selectedJuror = useMemo(
- () => juryMembers?.find((j: any) => j.id === selectedJurorId),
- [juryMembers, selectedJurorId],
- )
-
- // Filter projects by search term
- const filteredProjects = useMemo(() => {
- const items = projectStates ?? []
- if (!projectSearch) return items
- const q = projectSearch.toLowerCase()
- return items.filter((ps: any) =>
- ps.project?.title?.toLowerCase().includes(q) ||
- ps.project?.teamName?.toLowerCase().includes(q) ||
- ps.project?.competitionCategory?.toLowerCase().includes(q)
- )
- }, [projectStates, projectSearch])
-
- // Existing assignments for the selected juror (to grey out already-assigned projects)
- const jurorExistingProjectIds = useMemo(() => {
- if (!selectedJurorId || !assignments) return new Set()
- return new Set(
- assignments
- .filter((a: any) => a.userId === selectedJurorId)
- .map((a: any) => a.projectId)
- )
- }, [selectedJurorId, assignments])
-
- const toggleProject = useCallback((projectId: string) => {
- setSelectedProjectIds(prev => {
- const next = new Set(prev)
- if (next.has(projectId)) {
- next.delete(projectId)
- } else {
- next.add(projectId)
- }
- return next
- })
- }, [])
-
- const selectAllUnassigned = useCallback(() => {
- const unassigned = filteredProjects
- .filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
- .map((ps: any) => ps.project?.id)
- .filter(Boolean)
- setSelectedProjectIds(new Set(unassigned))
- }, [filteredProjects, jurorExistingProjectIds])
-
- const handleCreate = useCallback(() => {
- if (!selectedJurorId || selectedProjectIds.size === 0) return
-
- const projectIds = Array.from(selectedProjectIds)
- if (projectIds.length === 1) {
- createMutation.mutate({
- userId: selectedJurorId,
- projectId: projectIds[0],
- roundId,
- })
- } else {
- bulkCreateMutation.mutate({
- roundId,
- assignments: projectIds.map(projectId => ({
- userId: selectedJurorId,
- projectId,
- })),
- })
- }
- }, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
-
- const isMutating = createMutation.isPending || bulkCreateMutation.isPending
-
- // ── By Project mode helpers ──
-
- // Existing assignments for the selected project (to grey out already-assigned jurors)
- const projectExistingJurorIds = useMemo(() => {
- if (!selectedProjectId || !assignments) return new Set()
- return new Set(
- assignments
- .filter((a: any) => a.projectId === selectedProjectId)
- .map((a: any) => a.userId)
- )
- }, [selectedProjectId, assignments])
-
- // Count assignments per juror in this round (for display)
- const jurorAssignmentCounts = useMemo(() => {
- if (!assignments) return new Map()
- const counts = new Map()
- for (const a of assignments) {
- counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
- }
- return counts
- }, [assignments])
-
- // Filter jurors by search term
- const filteredJurors = useMemo(() => {
- const items = juryMembers ?? []
- if (!jurorSearch) return items
- const q = jurorSearch.toLowerCase()
- return items.filter((j: any) =>
- j.name?.toLowerCase().includes(q) ||
- j.email?.toLowerCase().includes(q)
- )
- }, [juryMembers, jurorSearch])
-
- const toggleJuror = useCallback((jurorId: string) => {
- setSelectedJurorIds(prev => {
- const next = new Set(prev)
- if (next.has(jurorId)) next.delete(jurorId)
- else next.add(jurorId)
- return next
- })
- }, [])
-
- const handleCreateByProject = useCallback(() => {
- if (!selectedProjectId || selectedJurorIds.size === 0) return
-
- const jurorIds = Array.from(selectedJurorIds)
- if (jurorIds.length === 1) {
- createMutation.mutate({
- userId: jurorIds[0],
- projectId: selectedProjectId,
- roundId,
- })
- } else {
- bulkCreateMutation.mutate({
- roundId,
- assignments: jurorIds.map(userId => ({
- userId,
- projectId: selectedProjectId,
- })),
- })
- }
- }, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
-
- return (
-
-
-
-
{assignments?.length ?? 0} individual assignments
-
-
setAddDialogOpen(true)}>
-
- Add
-
-
-
- {isLoading ? (
-
- {[1, 2, 3, 4, 5].map((i) => )}
-
- ) : !assignments || assignments.length === 0 ? (
-
- No assignments yet. Generate assignments or add one manually.
-
- ) : (
-
-
- Juror
- Project
- Status
- Actions
-
- {assignments.map((a: any, idx: number) => (
-
-
{a.user?.name || a.user?.email || 'Unknown'}
-
{a.project?.title || 'Unknown'}
-
- {a.conflictOfInterest?.hasConflict ? (
-
- COI
-
- ) : (
-
- {a.evaluation?.status || 'PENDING'}
-
- )}
-
-
-
-
-
-
-
-
- {a.conflictOfInterest?.hasConflict && (
- <>
- reassignCOIMutation.mutate({ assignmentId: a.id })}
- disabled={reassignCOIMutation.isPending}
- >
-
- Reassign (COI)
-
-
- >
- )}
- {a.evaluation && (
- <>
- setConfirmAction({ type: 'reset', assignment: a })}
- disabled={resetEvalMutation.isPending}
- >
-
- Reset Evaluation
-
-
- >
- )}
- setConfirmAction({ type: 'delete', assignment: a })}
- disabled={deleteMutation.isPending}
- >
-
- Delete Assignment
-
-
-
-
- ))}
-
- )}
-
-
- {/* Add Assignment Dialog */}
-
{
- if (!open) resetDialog()
- else setAddDialogOpen(true)
- }}>
-
-
- Add Assignment
-
- {assignMode === 'byJuror'
- ? 'Select a juror, then choose projects to assign'
- : 'Select a project, then choose jurors to assign'
- }
-
-
-
- {/* Mode Toggle */}
- {
- setAssignMode(v as 'byJuror' | 'byProject')
- // Reset selections when switching
- setSelectedJurorId('')
- setSelectedProjectIds(new Set())
- setProjectSearch('')
- setSelectedProjectId('')
- setSelectedJurorIds(new Set())
- setJurorSearch('')
- }}>
-
- By Juror
- By Project
-
-
- {/* ── By Juror Tab ── */}
-
- {/* Juror Selector */}
-
-
Juror
-
-
-
- {selectedJuror
- ? (
-
- {selectedJuror.name || selectedJuror.email}
-
- {selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
-
-
- )
- : Select a jury member...
- }
-
-
-
-
-
-
-
- No jury members found.
-
- {juryMembers?.map((juror: any) => {
- const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
- return (
- {
- setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
- setSelectedProjectIds(new Set())
- setJurorPopoverOpen(false)
- }}
- >
-
-
-
-
- {juror.name || 'Unnamed'}
-
-
- {juror.email}
-
-
-
- {juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
- {atCapacity ? ' full' : ''}
-
-
-
- )
- })}
-
-
-
-
-
-
-
- {/* Project Multi-Select */}
-
-
-
- Projects
- {selectedProjectIds.size > 0 && (
-
- ({selectedProjectIds.size} selected)
-
- )}
-
- {selectedJurorId && (
-
-
- Select all
-
- {selectedProjectIds.size > 0 && (
- setSelectedProjectIds(new Set())}
- >
- Clear
-
- )}
-
- )}
-
-
- {/* Search input */}
-
-
- setProjectSearch(e.target.value)}
- className="pl-9 h-9"
- />
-
-
- {/* Project checklist */}
-
-
- {!selectedJurorId ? (
-
- Select a juror first
-
- ) : filteredProjects.length === 0 ? (
-
- No projects found
-
- ) : (
- filteredProjects.map((ps: any) => {
- const project = ps.project
- if (!project) return null
- const alreadyAssigned = jurorExistingProjectIds.has(project.id)
- const isSelected = selectedProjectIds.has(project.id)
-
- return (
-
- toggleProject(project.id)}
- />
-
-
{project.title}
-
- {project.competitionCategory && (
-
- {project.competitionCategory === 'STARTUP'
- ? 'Startup'
- : project.competitionCategory === 'BUSINESS_CONCEPT'
- ? 'Concept'
- : project.competitionCategory}
-
- )}
- {alreadyAssigned && (
-
- Assigned
-
- )}
-
-
-
- )
- })
- )}
-
-
-
-
-
-
- Cancel
-
-
- {isMutating && }
- {selectedProjectIds.size <= 1
- ? 'Create Assignment'
- : `Create ${selectedProjectIds.size} Assignments`
- }
-
-
-
-
- {/* ── By Project Tab ── */}
-
- {/* Project Selector */}
-
-
Project
-
-
-
- {selectedProjectId
- ? (
-
- {(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
-
- )
- : Select a project...
- }
-
-
-
-
-
-
-
- No projects found.
-
- {(projectStates ?? []).map((ps: any) => {
- const project = ps.project
- if (!project) return null
- return (
- {
- setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
- setSelectedJurorIds(new Set())
- setProjectPopoverOpen(false)
- }}
- >
-
-
-
-
{project.title}
-
{project.teamName}
-
- {project.competitionCategory && (
-
- {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
-
- )}
-
-
- )
- })}
-
-
-
-
-
-
-
- {/* Juror Multi-Select */}
-
-
-
- Jurors
- {selectedJurorIds.size > 0 && (
-
- ({selectedJurorIds.size} selected)
-
- )}
-
- {selectedProjectId && selectedJurorIds.size > 0 && (
- setSelectedJurorIds(new Set())}
- >
- Clear
-
- )}
-
-
- {/* Search input */}
-
-
- setJurorSearch(e.target.value)}
- className="pl-9 h-9"
- />
-
-
- {/* Juror checklist */}
-
-
- {!selectedProjectId ? (
-
- Select a project first
-
- ) : filteredJurors.length === 0 ? (
-
- No jurors found
-
- ) : (
- filteredJurors.map((juror: any) => {
- const alreadyAssigned = projectExistingJurorIds.has(juror.id)
- const isSelected = selectedJurorIds.has(juror.id)
- const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
-
- return (
-
- toggleJuror(juror.id)}
- />
-
-
- {juror.name || 'Unnamed'}
- {juror.email}
-
-
-
- {assignCount} assigned
-
- {alreadyAssigned && (
-
- Already on project
-
- )}
-
-
-
- )
- })
- )}
-
-
-
-
-
-
- Cancel
-
-
- {isMutating && }
- {selectedJurorIds.size <= 1
- ? 'Create Assignment'
- : `Create ${selectedJurorIds.size} Assignments`
- }
-
-
-
-
-
-
-
- {/* 4.2 Confirmation AlertDialog for reset/delete (replaces native confirm) */}
-
{ if (!open) setConfirmAction(null) }}>
-
-
-
- {confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
-
-
- {confirmAction?.type === 'reset'
- ? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
- : `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
- }
-
-
-
- Cancel
- {
- if (confirmAction?.type === 'reset') {
- resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
- } else if (confirmAction?.type === 'delete') {
- deleteMutation.mutate({ id: confirmAction.assignment.id })
- }
- setConfirmAction(null)
- }}
- >
- {confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
-
-
-
-
-
- )
-}
-
-// ── Evaluation Criteria Editor ───────────────────────────────────────────
-
-// ── Advance Projects Dialog ─────────────────────────────────────────────
-
-function AdvanceProjectsDialog({
- open,
- onOpenChange,
- roundId,
- roundType,
- projectStates,
- config,
- advanceMutation,
- competitionRounds,
- currentSortOrder,
-}: {
- open: boolean
- onOpenChange: (open: boolean) => void
- roundId: string
- roundType?: string
- projectStates: any[] | undefined
- config: Record
- advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
- competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
- currentSortOrder?: number
-}) {
- // For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
- const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
- // Target round selector
- const availableTargets = useMemo(() =>
- (competitionRounds ?? [])
- .filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
- .sort((a, b) => a.sortOrder - b.sortOrder),
- [competitionRounds, currentSortOrder, roundId])
-
- const [targetRoundId, setTargetRoundId] = useState('')
-
- // Default to first available target when dialog opens
- if (open && !targetRoundId && availableTargets.length > 0) {
- setTargetRoundId(availableTargets[0].id)
- }
- const allProjects = projectStates ?? []
- const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
- const passedProjects = useMemo(() =>
- allProjects.filter((ps: any) => ps.state === 'PASSED'),
- [allProjects])
-
- 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 = (autoPass?: boolean) => {
- if (autoPass) {
- // Auto-pass all pending then advance all
- advanceMutation.mutate({
- roundId,
- autoPassPending: true,
- ...(targetRoundId ? { targetRoundId } : {}),
- })
- } else {
- const ids = Array.from(selected)
- if (ids.length === 0) return
- advanceMutation.mutate({
- roundId,
- projectIds: ids,
- ...(targetRoundId ? { targetRoundId } : {}),
- })
- }
- onOpenChange(false)
- setSelected(new Set())
- setTargetRoundId('')
- }
-
- const handleClose = () => {
- onOpenChange(false)
- setSelected(new Set())
- setTargetRoundId('')
- }
-
- 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) => (
-
- toggleProject(ps.project?.id)}
- />
- {ps.project?.title || 'Untitled'}
- {ps.project?.teamName && (
- {ps.project.teamName}
- )}
-
- ))}
-
- )}
-
- )
- }
-
- const totalProjectCount = allProjects.length
-
- return (
-
-
-
- Advance Projects
-
- {isSimpleAdvance
- ? `Move all ${totalProjectCount} projects to the next round.`
- : `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
- }
-
-
-
- {/* Target round selector */}
- {availableTargets.length > 0 && (
-
- Advance to
-
-
-
-
-
- {availableTargets.map((r) => (
-
- {r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
-
- ))}
-
-
-
- )}
- {availableTargets.length === 0 && (
-
- No subsequent rounds found. Projects will advance to the next round by sort order.
-
- )}
-
- {isSimpleAdvance ? (
- /* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
-
-
-
{totalProjectCount}
-
projects will be advanced
-
- {pendingCount > 0 && (
-
-
- {pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
- {passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
-
-
- )}
-
- ) : (
- /* Detailed mode for jury/evaluation rounds — per-project selection */
-
- {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')}
-
- )}
-
-
- Cancel
- {isSimpleAdvance ? (
- handleAdvance(true)}
- disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
- >
- {advanceMutation.isPending && }
- Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
-
- ) : (
- handleAdvance()}
- disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
- >
- {advanceMutation.isPending && }
- Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
-
- )}
-
-
-
- )
-}
-
-// ── AI Recommendations Display ──────────────────────────────────────────
-
-type RecommendationItem = {
- projectId: string
- rank: number
- score: number
- category: string
- strengths: string[]
- concerns: string[]
- recommendation: string
-}
-
-function AIRecommendationsDisplay({
- recommendations,
- projectStates,
- roundId,
- onClear,
- onApplied,
-}: {
- recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
- projectStates: any[] | undefined
- roundId: string
- onClear: () => void
- onApplied: () => void
-}) {
- const [expandedId, setExpandedId] = useState(null)
- const [applying, setApplying] = useState(false)
-
- // Initialize selected with all recommended project IDs
- const allRecommendedIds = useMemo(() => {
- const ids = new Set()
- for (const item of recommendations.STARTUP) ids.add(item.projectId)
- for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
- return ids
- }, [recommendations])
-
- const [selectedIds, setSelectedIds] = useState>(() => new Set(allRecommendedIds))
-
- // Build projectId → title map from projectStates
- const projectTitleMap = useMemo(() => {
- const map = new Map()
- if (projectStates) {
- for (const ps of projectStates) {
- if (ps.project?.id && ps.project?.title) {
- map.set(ps.project.id, ps.project.title)
- }
- }
- }
- return map
- }, [projectStates])
-
- const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
-
- const toggleProject = (projectId: string) => {
- setSelectedIds((prev) => {
- const next = new Set(prev)
- if (next.has(projectId)) next.delete(projectId)
- else next.add(projectId)
- return next
- })
- }
-
- const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
- const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
-
- const handleApply = async () => {
- setApplying(true)
- try {
- // Transition all selected projects to PASSED
- const promises = Array.from(selectedIds).map((projectId) =>
- transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
- // Project might already be PASSED — that's OK
- })
- )
- await Promise.all(promises)
- toast.success(`Marked ${selectedIds.size} project(s) as passed`)
- onApplied()
- } catch (error) {
- toast.error('Failed to apply recommendations')
- } finally {
- setApplying(false)
- }
- }
-
- 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}`
- const isSelected = selectedIds.has(item.projectId)
- const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
-
- return (
-
-
-
toggleProject(item.projectId)}
- className="shrink-0"
- />
- setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
- className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
- >
-
- {item.rank}
-
-
-
{projectTitle}
-
{item.recommendation}
-
-
- {item.score}/100
-
-
-
-
- {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 — {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
-
-
-
-
- Dismiss
-
-
-
-
-
-
-
-
- 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')}
-
-
-
- {/* Apply button */}
-
-
- {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as Passed
-
-
- {applying ? (
- <> Applying...>
- ) : (
- <> Apply & Mark as Passed>
- )}
-
-
-
-
- )
-}
-
-// ── Evaluation Criteria Editor ───────────────────────────────────────────
-
-function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
- const [pendingCriteria, setPendingCriteria] = useState(null)
- const utils = trpc.useUtils()
-
- const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
- { roundId },
- { refetchInterval: 30_000 },
- )
-
- const upsertMutation = trpc.evaluation.upsertForm.useMutation({
- onSuccess: () => {
- utils.evaluation.getForm.invalidate({ roundId })
- toast.success('Evaluation criteria saved')
- setPendingCriteria(null)
- },
- onError: (err) => toast.error(err.message),
- })
-
- // Convert server criteriaJson to Criterion[] format
- const serverCriteria: Criterion[] = useMemo(() => {
- if (!form?.criteriaJson) return []
- return (form.criteriaJson as Criterion[]).map((c) => {
- // Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
- const type = c.type || 'numeric'
- if (type === 'numeric' && typeof c.scale === 'string') {
- const parts = (c.scale as string).split('-').map(Number)
- if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
- return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
- }
- }
- return { ...c, type } as Criterion
- })
- }, [form?.criteriaJson])
-
- const handleChange = useCallback((criteria: Criterion[]) => {
- setPendingCriteria(criteria)
- }, [])
-
- const handleSave = () => {
- const criteria = pendingCriteria ?? serverCriteria
- const validCriteria = criteria.filter((c) => c.label.trim())
- if (validCriteria.length === 0) {
- toast.error('Add at least one criterion')
- return
- }
- // Map to upsertForm format
- upsertMutation.mutate({
- roundId,
- criteria: validCriteria.map((c) => ({
- id: c.id,
- label: c.label,
- description: c.description,
- type: c.type || 'numeric',
- weight: c.weight,
- scale: typeof c.scale === 'number' ? c.scale : undefined,
- minScore: (c as any).minScore,
- maxScore: (c as any).maxScore,
- required: c.required,
- maxLength: c.maxLength,
- placeholder: c.placeholder,
- trueLabel: c.trueLabel,
- falseLabel: c.falseLabel,
- condition: c.condition,
- sectionId: c.sectionId,
- })),
- })
- }
-
- return (
-
-
-
-
- Evaluation Criteria
-
- {form
- ? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
- : 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
-
-
- {pendingCriteria && (
-
- setPendingCriteria(null)}>
- Cancel
-
-
- {upsertMutation.isPending && }
- Save Criteria
-
-
- )}
-
-
-
- {isLoading ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : (
-
- )}
-
-
- )
-}
-
-// ── COI Review Section ────────────────────────────────────────────────────
-
-function COIReviewSection({ roundId }: { roundId: string }) {
- const utils = trpc.useUtils()
- const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
- { roundId },
- { refetchInterval: 15_000 },
- )
-
- const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
- onSuccess: (data) => {
- utils.evaluation.listCOIByStage.invalidate({ roundId })
- utils.assignment.listByStage.invalidate({ roundId })
- utils.analytics.getJurorWorkload.invalidate({ roundId })
- if (data.reassignment) {
- toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
- } else {
- toast.success('COI review updated')
- }
- },
- onError: (err) => toast.error(err.message),
- })
-
- // Show placeholder when no declarations
- if (!isLoading && (!declarations || declarations.length === 0)) {
- return (
-
-
No conflict of interest declarations yet.
-
- )
- }
-
- const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
- const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
-
- return (
-
-
-
-
-
- Conflict of Interest Declarations
-
-
- {declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
- {conflictCount > 0 && (
- <> — {conflictCount} conflict{conflictCount !== 1 ? 's' : ''} >
- )}
- {unreviewedCount > 0 && (
- <> ({unreviewedCount} pending review)>
- )}
-
-
-
-
- {isLoading ? (
-
- {[1, 2, 3].map((i) => )}
-
- ) : (
-
-
- Juror
- Project
- Conflict
- Type
- Action
-
- {declarations?.map((coi: any, idx: number) => (
-
- {coi.user?.name || coi.user?.email || 'Unknown'}
- {coi.assignment?.project?.title || 'Unknown'}
-
- {coi.hasConflict ? 'Yes' : 'No'}
-
-
- {coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
-
- {coi.hasConflict ? (
- coi.reviewedAt ? (
-
- {coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
-
- ) : (
-
-
-
-
- Review
-
-
-
- reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
- disabled={reviewMutation.isPending}
- >
-
- Clear — no real conflict
-
- reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
- disabled={reviewMutation.isPending}
- >
-
- Reassign to another juror
-
- reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
- disabled={reviewMutation.isPending}
- >
-
- Note — keep as is
-
-
-
- )
- ) : (
- —
- )}
-
- ))}
-
- )}
-
-
- )
-}
diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx
index 210735f..e5f80ab 100644
--- a/src/app/(admin)/admin/rounds/page.tsx
+++ b/src/app/(admin)/admin/rounds/page.tsx
@@ -46,43 +46,28 @@ import {
ArrowRight,
} from 'lucide-react'
import { useEdition } from '@/contexts/edition-context'
+import {
+ roundTypeConfig,
+ roundStatusConfig,
+ awardStatusConfig,
+ ROUND_TYPE_OPTIONS,
+} from '@/lib/round-config'
-// ─── Constants ───────────────────────────────────────────────────────────────
+// ─── Constants (derived from shared config) ──────────────────────────────────
-const ROUND_TYPES = [
- { value: 'INTAKE', label: 'Intake' },
- { value: 'FILTERING', label: 'Filtering' },
- { value: 'EVALUATION', label: 'Evaluation' },
- { value: 'SUBMISSION', label: 'Submission' },
- { value: 'MENTORING', label: 'Mentoring' },
- { value: 'LIVE_FINAL', label: 'Live Final' },
- { value: 'DELIBERATION', label: 'Deliberation' },
-] as const
+const ROUND_TYPES = ROUND_TYPE_OPTIONS
-const ROUND_TYPE_COLORS: Record = {
- INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' },
- FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' },
- EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' },
- SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' },
- MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' },
- LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' },
- DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' },
-}
+const ROUND_TYPE_COLORS: Record = Object.fromEntries(
+ Object.entries(roundTypeConfig).map(([k, v]) => [k, { dot: v.dotColor, bg: v.cardBg, text: v.cardText, border: v.cardBorder }])
+)
-const ROUND_STATUS_STYLES: Record = {
- ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' },
- ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true },
- ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' },
- ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' },
-}
+const ROUND_STATUS_STYLES: Record = Object.fromEntries(
+ Object.entries(roundStatusConfig).map(([k, v]) => [k, { color: v.dotColor, label: v.label, pulse: v.pulse }])
+)
-const AWARD_STATUS_COLORS: Record = {
- DRAFT: 'text-gray-500',
- NOMINATIONS_OPEN: 'text-amber-600',
- VOTING_OPEN: 'text-emerald-600',
- CLOSED: 'text-blue-600',
- ARCHIVED: 'text-gray-400',
-}
+const AWARD_STATUS_COLORS: Record = Object.fromEntries(
+ Object.entries(awardStatusConfig).map(([k, v]) => [k, v.color])
+)
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -268,12 +253,12 @@ export default function RoundsPage() {
No Competition Configured
- Create a competition to start building the evaluation pipeline.
+ Create a program edition to start building the evaluation pipeline.
-
+
- Create Competition
+ Manage Editions
diff --git a/src/components/admin/assignment/coi-review-section.tsx b/src/components/admin/assignment/coi-review-section.tsx
new file mode 100644
index 0000000..2d3454e
--- /dev/null
+++ b/src/components/admin/assignment/coi-review-section.tsx
@@ -0,0 +1,170 @@
+'use client'
+
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { ShieldAlert, Eye, CheckCircle2, UserPlus, FileText } from 'lucide-react'
+
+export type COIReviewSectionProps = {
+ roundId: string
+}
+
+export function COIReviewSection({ roundId }: COIReviewSectionProps) {
+ const utils = trpc.useUtils()
+ const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
+ { roundId },
+ { refetchInterval: 15_000 },
+ )
+
+ const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
+ onSuccess: (data) => {
+ utils.evaluation.listCOIByStage.invalidate({ roundId })
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.analytics.getJurorWorkload.invalidate({ roundId })
+ if (data.reassignment) {
+ toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
+ } else {
+ toast.success('COI review updated')
+ }
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Show placeholder when no declarations
+ if (!isLoading && (!declarations || declarations.length === 0)) {
+ return (
+
+
No conflict of interest declarations yet.
+
+ )
+ }
+
+ const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
+ const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
+
+ return (
+
+
+
+
+
+ Conflict of Interest Declarations
+
+
+ {declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
+ {conflictCount > 0 && (
+ <> — {conflictCount} conflict{conflictCount !== 1 ? 's' : ''} >
+ )}
+ {unreviewedCount > 0 && (
+ <> ({unreviewedCount} pending review)>
+ )}
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : (
+
+
+ Juror
+ Project
+ Conflict
+ Type
+ Action
+
+ {declarations?.map((coi: any, idx: number) => (
+
+ {coi.user?.name || coi.user?.email || 'Unknown'}
+ {coi.assignment?.project?.title || 'Unknown'}
+
+ {coi.hasConflict ? 'Yes' : 'No'}
+
+
+ {coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
+
+ {coi.hasConflict ? (
+ coi.reviewedAt ? (
+
+ {coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
+
+ ) : (
+
+
+
+
+ Review
+
+
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Clear — no real conflict
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Reassign to another juror
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Note — keep as is
+
+
+
+ )
+ ) : (
+ —
+ )}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/admin/assignment/individual-assignments-table.tsx b/src/components/admin/assignment/individual-assignments-table.tsx
new file mode 100644
index 0000000..2c15cc5
--- /dev/null
+++ b/src/components/admin/assignment/individual-assignments-table.tsx
@@ -0,0 +1,854 @@
+'use client'
+
+import { useState, useMemo, useCallback } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+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 { 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,
+} from '@/components/ui/alert-dialog'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ Loader2,
+ Plus,
+ Trash2,
+ RotateCcw,
+ Check,
+ ChevronsUpDown,
+ Search,
+ MoreHorizontal,
+ UserPlus,
+} from 'lucide-react'
+
+export type IndividualAssignmentsTableProps = {
+ roundId: string
+ projectStates: any[] | undefined
+}
+
+export function IndividualAssignmentsTable({
+ roundId,
+ projectStates,
+}: IndividualAssignmentsTableProps) {
+ const [addDialogOpen, setAddDialogOpen] = useState(false)
+ const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
+ const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
+ // ── By Juror mode state ──
+ const [selectedJurorId, setSelectedJurorId] = useState('')
+ const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set())
+ const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
+ const [projectSearch, setProjectSearch] = useState('')
+ // ── By Project mode state ──
+ const [selectedProjectId, setSelectedProjectId] = useState('')
+ const [selectedJurorIds, setSelectedJurorIds] = useState>(new Set())
+ const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
+ const [jurorSearch, setJurorSearch] = useState('')
+
+ const utils = trpc.useUtils()
+ const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
+ { roundId },
+ { refetchInterval: 15_000 },
+ )
+
+ const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
+ { roundId },
+ { enabled: addDialogOpen },
+ )
+
+ const deleteMutation = trpc.assignment.delete.useMutation({
+ onSuccess: () => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ toast.success('Assignment removed')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({
+ onSuccess: () => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ toast.success('Evaluation reset — juror can now start over')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
+ onSuccess: (data) => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ utils.analytics.getJurorWorkload.invalidate({ roundId })
+ utils.evaluation.listCOIByStage.invalidate({ roundId })
+ toast.success(`Reassigned to ${data.newJurorName}`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const createMutation = trpc.assignment.create.useMutation({
+ onSuccess: () => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ utils.user.getJuryMembers.invalidate({ roundId })
+ toast.success('Assignment created')
+ resetDialog()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
+ onSuccess: (result) => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ utils.user.getJuryMembers.invalidate({ roundId })
+ toast.success(`${result.created} assignment(s) created`)
+ resetDialog()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const resetDialog = useCallback(() => {
+ setAddDialogOpen(false)
+ setAssignMode('byJuror')
+ setSelectedJurorId('')
+ setSelectedProjectIds(new Set())
+ setProjectSearch('')
+ setSelectedProjectId('')
+ setSelectedJurorIds(new Set())
+ setJurorSearch('')
+ }, [])
+
+ const selectedJuror = useMemo(
+ () => juryMembers?.find((j: any) => j.id === selectedJurorId),
+ [juryMembers, selectedJurorId],
+ )
+
+ // Filter projects by search term
+ const filteredProjects = useMemo(() => {
+ const items = projectStates ?? []
+ if (!projectSearch) return items
+ const q = projectSearch.toLowerCase()
+ return items.filter((ps: any) =>
+ ps.project?.title?.toLowerCase().includes(q) ||
+ ps.project?.teamName?.toLowerCase().includes(q) ||
+ ps.project?.competitionCategory?.toLowerCase().includes(q)
+ )
+ }, [projectStates, projectSearch])
+
+ // Existing assignments for the selected juror (to grey out already-assigned projects)
+ const jurorExistingProjectIds = useMemo(() => {
+ if (!selectedJurorId || !assignments) return new Set()
+ return new Set(
+ assignments
+ .filter((a: any) => a.userId === selectedJurorId)
+ .map((a: any) => a.projectId)
+ )
+ }, [selectedJurorId, assignments])
+
+ const toggleProject = useCallback((projectId: string) => {
+ setSelectedProjectIds(prev => {
+ const next = new Set(prev)
+ if (next.has(projectId)) {
+ next.delete(projectId)
+ } else {
+ next.add(projectId)
+ }
+ return next
+ })
+ }, [])
+
+ const selectAllUnassigned = useCallback(() => {
+ const unassigned = filteredProjects
+ .filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
+ .map((ps: any) => ps.project?.id)
+ .filter(Boolean)
+ setSelectedProjectIds(new Set(unassigned))
+ }, [filteredProjects, jurorExistingProjectIds])
+
+ const handleCreate = useCallback(() => {
+ if (!selectedJurorId || selectedProjectIds.size === 0) return
+
+ const projectIds = Array.from(selectedProjectIds)
+ if (projectIds.length === 1) {
+ createMutation.mutate({
+ userId: selectedJurorId,
+ projectId: projectIds[0],
+ roundId,
+ })
+ } else {
+ bulkCreateMutation.mutate({
+ roundId,
+ assignments: projectIds.map(projectId => ({
+ userId: selectedJurorId,
+ projectId,
+ })),
+ })
+ }
+ }, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
+
+ const isMutating = createMutation.isPending || bulkCreateMutation.isPending
+
+ // ── By Project mode helpers ──
+
+ // Existing assignments for the selected project (to grey out already-assigned jurors)
+ const projectExistingJurorIds = useMemo(() => {
+ if (!selectedProjectId || !assignments) return new Set()
+ return new Set(
+ assignments
+ .filter((a: any) => a.projectId === selectedProjectId)
+ .map((a: any) => a.userId)
+ )
+ }, [selectedProjectId, assignments])
+
+ // Count assignments per juror in this round (for display)
+ const jurorAssignmentCounts = useMemo(() => {
+ if (!assignments) return new Map()
+ const counts = new Map()
+ for (const a of assignments) {
+ counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
+ }
+ return counts
+ }, [assignments])
+
+ // Filter jurors by search term
+ const filteredJurors = useMemo(() => {
+ const items = juryMembers ?? []
+ if (!jurorSearch) return items
+ const q = jurorSearch.toLowerCase()
+ return items.filter((j: any) =>
+ j.name?.toLowerCase().includes(q) ||
+ j.email?.toLowerCase().includes(q)
+ )
+ }, [juryMembers, jurorSearch])
+
+ const toggleJuror = useCallback((jurorId: string) => {
+ setSelectedJurorIds(prev => {
+ const next = new Set(prev)
+ if (next.has(jurorId)) next.delete(jurorId)
+ else next.add(jurorId)
+ return next
+ })
+ }, [])
+
+ const handleCreateByProject = useCallback(() => {
+ if (!selectedProjectId || selectedJurorIds.size === 0) return
+
+ const jurorIds = Array.from(selectedJurorIds)
+ if (jurorIds.length === 1) {
+ createMutation.mutate({
+ userId: jurorIds[0],
+ projectId: selectedProjectId,
+ roundId,
+ })
+ } else {
+ bulkCreateMutation.mutate({
+ roundId,
+ assignments: jurorIds.map(userId => ({
+ userId,
+ projectId: selectedProjectId,
+ })),
+ })
+ }
+ }, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
+
+ return (
+
+
+
+
{assignments?.length ?? 0} individual assignments
+
+
setAddDialogOpen(true)}>
+
+ Add
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => )}
+
+ ) : !assignments || assignments.length === 0 ? (
+
+ No assignments yet. Generate assignments or add one manually.
+
+ ) : (
+
+
+ Juror
+ Project
+ Status
+ Actions
+
+ {assignments.map((a: any, idx: number) => (
+
+
{a.user?.name || a.user?.email || 'Unknown'}
+
{a.project?.title || 'Unknown'}
+
+ {a.conflictOfInterest?.hasConflict ? (
+
+ COI
+
+ ) : (
+
+ {a.evaluation?.status || 'PENDING'}
+
+ )}
+
+
+
+
+
+
+
+
+ {a.conflictOfInterest?.hasConflict && (
+ <>
+ reassignCOIMutation.mutate({ assignmentId: a.id })}
+ disabled={reassignCOIMutation.isPending}
+ >
+
+ Reassign (COI)
+
+
+ >
+ )}
+ {a.evaluation && (
+ <>
+ setConfirmAction({ type: 'reset', assignment: a })}
+ disabled={resetEvalMutation.isPending}
+ >
+
+ Reset Evaluation
+
+
+ >
+ )}
+ setConfirmAction({ type: 'delete', assignment: a })}
+ disabled={deleteMutation.isPending}
+ >
+
+ Delete Assignment
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Add Assignment Dialog */}
+
{
+ if (!open) resetDialog()
+ else setAddDialogOpen(true)
+ }}>
+
+
+ Add Assignment
+
+ {assignMode === 'byJuror'
+ ? 'Select a juror, then choose projects to assign'
+ : 'Select a project, then choose jurors to assign'
+ }
+
+
+
+ {/* Mode Toggle */}
+ {
+ setAssignMode(v as 'byJuror' | 'byProject')
+ // Reset selections when switching
+ setSelectedJurorId('')
+ setSelectedProjectIds(new Set())
+ setProjectSearch('')
+ setSelectedProjectId('')
+ setSelectedJurorIds(new Set())
+ setJurorSearch('')
+ }}>
+
+ By Juror
+ By Project
+
+
+ {/* ── By Juror Tab ── */}
+
+ {/* Juror Selector */}
+
+
Juror
+
+
+
+ {selectedJuror
+ ? (
+
+ {selectedJuror.name || selectedJuror.email}
+
+ {selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
+
+
+ )
+ : Select a jury member...
+ }
+
+
+
+
+
+
+
+ No jury members found.
+
+ {juryMembers?.map((juror: any) => {
+ const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
+ return (
+ {
+ setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
+ setSelectedProjectIds(new Set())
+ setJurorPopoverOpen(false)
+ }}
+ >
+
+
+
+
+ {juror.name || 'Unnamed'}
+
+
+ {juror.email}
+
+
+
+ {juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
+ {atCapacity ? ' full' : ''}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ {/* Project Multi-Select */}
+
+
+
+ Projects
+ {selectedProjectIds.size > 0 && (
+
+ ({selectedProjectIds.size} selected)
+
+ )}
+
+ {selectedJurorId && (
+
+
+ Select all
+
+ {selectedProjectIds.size > 0 && (
+ setSelectedProjectIds(new Set())}
+ >
+ Clear
+
+ )}
+
+ )}
+
+
+ {/* Search input */}
+
+
+ setProjectSearch(e.target.value)}
+ className="pl-9 h-9"
+ />
+
+
+ {/* Project checklist */}
+
+
+ {!selectedJurorId ? (
+
+ Select a juror first
+
+ ) : filteredProjects.length === 0 ? (
+
+ No projects found
+
+ ) : (
+ filteredProjects.map((ps: any) => {
+ const project = ps.project
+ if (!project) return null
+ const alreadyAssigned = jurorExistingProjectIds.has(project.id)
+ const isSelected = selectedProjectIds.has(project.id)
+
+ return (
+
+ toggleProject(project.id)}
+ />
+
+
{project.title}
+
+ {project.competitionCategory && (
+
+ {project.competitionCategory === 'STARTUP'
+ ? 'Startup'
+ : project.competitionCategory === 'BUSINESS_CONCEPT'
+ ? 'Concept'
+ : project.competitionCategory}
+
+ )}
+ {alreadyAssigned && (
+
+ Assigned
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+ {isMutating && }
+ {selectedProjectIds.size <= 1
+ ? 'Create Assignment'
+ : `Create ${selectedProjectIds.size} Assignments`
+ }
+
+
+
+
+ {/* ── By Project Tab ── */}
+
+ {/* Project Selector */}
+
+
Project
+
+
+
+ {selectedProjectId
+ ? (
+
+ {(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
+
+ )
+ : Select a project...
+ }
+
+
+
+
+
+
+
+ No projects found.
+
+ {(projectStates ?? []).map((ps: any) => {
+ const project = ps.project
+ if (!project) return null
+ return (
+ {
+ setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
+ setSelectedJurorIds(new Set())
+ setProjectPopoverOpen(false)
+ }}
+ >
+
+
+
+
{project.title}
+
{project.teamName}
+
+ {project.competitionCategory && (
+
+ {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ {/* Juror Multi-Select */}
+
+
+
+ Jurors
+ {selectedJurorIds.size > 0 && (
+
+ ({selectedJurorIds.size} selected)
+
+ )}
+
+ {selectedProjectId && selectedJurorIds.size > 0 && (
+ setSelectedJurorIds(new Set())}
+ >
+ Clear
+
+ )}
+
+
+ {/* Search input */}
+
+
+ setJurorSearch(e.target.value)}
+ className="pl-9 h-9"
+ />
+
+
+ {/* Juror checklist */}
+
+
+ {!selectedProjectId ? (
+
+ Select a project first
+
+ ) : filteredJurors.length === 0 ? (
+
+ No jurors found
+
+ ) : (
+ filteredJurors.map((juror: any) => {
+ const alreadyAssigned = projectExistingJurorIds.has(juror.id)
+ const isSelected = selectedJurorIds.has(juror.id)
+ const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
+
+ return (
+
+ toggleJuror(juror.id)}
+ />
+
+
+ {juror.name || 'Unnamed'}
+ {juror.email}
+
+
+
+ {assignCount} assigned
+
+ {alreadyAssigned && (
+
+ Already on project
+
+ )}
+
+
+
+ )
+ })
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+ {isMutating && }
+ {selectedJurorIds.size <= 1
+ ? 'Create Assignment'
+ : `Create ${selectedJurorIds.size} Assignments`
+ }
+
+
+
+
+
+
+
+ {/* Confirmation AlertDialog for reset/delete */}
+
{ if (!open) setConfirmAction(null) }}>
+
+
+
+ {confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
+
+
+ {confirmAction?.type === 'reset'
+ ? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
+ : `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
+ }
+
+
+
+ Cancel
+ {
+ if (confirmAction?.type === 'reset') {
+ resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
+ } else if (confirmAction?.type === 'delete') {
+ deleteMutation.mutate({ id: confirmAction.assignment.id })
+ }
+ setConfirmAction(null)
+ }}
+ >
+ {confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/assignment/jury-progress-table.tsx b/src/components/admin/assignment/jury-progress-table.tsx
new file mode 100644
index 0000000..71d3a84
--- /dev/null
+++ b/src/components/admin/assignment/jury-progress-table.tsx
@@ -0,0 +1,180 @@
+'use client'
+
+import { useState } from 'react'
+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 { Skeleton } from '@/components/ui/skeleton'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react'
+import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
+
+export type JuryProgressTableProps = {
+ roundId: string
+}
+
+export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
+ const utils = trpc.useUtils()
+ const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
+
+ const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
+ { roundId },
+ { refetchInterval: 15_000 },
+ )
+
+ const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
+ onSuccess: (data) => {
+ toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
+ onSuccess: (data) => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.roundEngine.getProjectStates.invalidate({ roundId })
+ utils.analytics.getJurorWorkload.invalidate({ roundId })
+ utils.roundAssignment.unassignedQueue.invalidate({ roundId })
+
+ if (data.failedCount > 0) {
+ toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
+ } else {
+ toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
+ }
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ return (
+ <>
+
+
+ Jury Progress
+ Evaluation completion per juror. Click the mail icon to notify an individual juror.
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : !workload || workload.length === 0 ? (
+
+ No assignments yet
+
+ ) : (
+
+ {workload.map((juror) => {
+ const pct = juror.completionRate
+ const barGradient = pct === 100
+ ? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
+ : pct >= 50
+ ? 'bg-gradient-to-r from-blue-400 to-blue-600'
+ : pct > 0
+ ? 'bg-gradient-to-r from-amber-400 to-amber-600'
+ : 'bg-gray-300'
+
+ return (
+
+
+
{juror.name}
+
+
+ {juror.completed}/{juror.assigned} ({pct}%)
+
+
+
+
+ notifyMutation.mutate({ roundId, userId: juror.id })}
+ >
+ {notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
+
+ ) : (
+
+ )}
+
+
+ Notify this juror of their assignments
+
+
+
+
+
+
+ setTransferJuror({ id: juror.id, name: juror.name })}
+ >
+
+
+
+ Transfer assignments to other jurors
+
+
+
+
+
+
+ {
+ const ok = window.confirm(
+ `Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
+ )
+ if (!ok) return
+ reshuffleMutation.mutate({ roundId, jurorId: juror.id })
+ }}
+ >
+ {reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
+
+ ) : (
+
+ )}
+
+
+ Drop juror + reshuffle pending projects
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {transferJuror && (
+ setTransferJuror(null)}
+ />
+ )}
+ >
+ )
+}
diff --git a/src/components/admin/assignment/notify-jurors-button.tsx b/src/components/admin/assignment/notify-jurors-button.tsx
new file mode 100644
index 0000000..a23bcb5
--- /dev/null
+++ b/src/components/admin/assignment/notify-jurors-button.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Mail, Loader2 } from 'lucide-react'
+
+export type NotifyJurorsButtonProps = {
+ roundId: string
+}
+
+export function NotifyJurorsButton({ roundId }: NotifyJurorsButtonProps) {
+ const [open, setOpen] = useState(false)
+ const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
+ onSuccess: (data) => {
+ toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
+ setOpen(false)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ return (
+ <>
+ setOpen(true)}>
+
+ Notify Jurors
+
+
+
+
+ Notify jurors of their assignments?
+
+ This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
+
+
+
+ Cancel
+ mutation.mutate({ roundId })}
+ disabled={mutation.isPending}
+ >
+ {mutation.isPending && }
+ Notify Jurors
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/admin/assignment/reassignment-history.tsx b/src/components/admin/assignment/reassignment-history.tsx
new file mode 100644
index 0000000..ffc7182
--- /dev/null
+++ b/src/components/admin/assignment/reassignment-history.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { cn } from '@/lib/utils'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Badge } from '@/components/ui/badge'
+import { History, ChevronRight } from 'lucide-react'
+
+export type ReassignmentHistoryProps = {
+ roundId: string
+}
+
+export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
+ const [expanded, setExpanded] = useState(false)
+ const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
+ { roundId },
+ { enabled: expanded },
+ )
+
+ return (
+
+ setExpanded(!expanded)}
+ >
+
+
+ Reassignment History
+
+
+ Juror dropout, COI, transfer, and cap redistribution audit trail
+
+ {expanded && (
+
+ {isLoading ? (
+
+ {[1, 2].map((i) => )}
+
+ ) : !events || events.length === 0 ? (
+
+ No reassignment events for this round
+
+ ) : (
+
+ {events.map((event) => (
+
+
+
+
+ {event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
+
+
+ {event.droppedJuror.name}
+
+
+
+ {new Date(event.timestamp).toLocaleString()}
+
+
+
+
+ By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
+ {event.failedCount > 0 && `, ${event.failedCount} failed`}
+
+
+ {event.moves.length > 0 && (
+
+
+
+
+ Project
+ Reassigned To
+
+
+
+ {event.moves.map((move, i) => (
+
+
+ {move.projectTitle}
+
+
+ {move.newJurorName}
+
+
+ ))}
+
+
+
+ )}
+
+ {event.failedProjects.length > 0 && (
+
+
Could not reassign:
+
+ {event.failedProjects.map((p, i) => (
+ {p}
+ ))}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/components/admin/assignment/round-unassigned-queue.tsx b/src/components/admin/assignment/round-unassigned-queue.tsx
new file mode 100644
index 0000000..ec3e39b
--- /dev/null
+++ b/src/components/admin/assignment/round-unassigned-queue.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { trpc } from '@/lib/trpc/client'
+import { cn } from '@/lib/utils'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Badge } from '@/components/ui/badge'
+
+export type RoundUnassignedQueueProps = {
+ roundId: string
+ requiredReviews?: number
+}
+
+export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
+ const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
+ { roundId, requiredReviews },
+ { refetchInterval: 15_000 },
+ )
+
+ return (
+
+
+
Unassigned Projects
+
Projects with fewer than {requiredReviews} 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} / {requiredReviews}
+
+
+ ))}
+
+ ) : (
+
+ All projects have sufficient assignments
+
+ )}
+
+
+ )
+}
diff --git a/src/components/admin/assignment/send-reminders-button.tsx b/src/components/admin/assignment/send-reminders-button.tsx
new file mode 100644
index 0000000..264188d
--- /dev/null
+++ b/src/components/admin/assignment/send-reminders-button.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Send, Loader2 } from 'lucide-react'
+
+export type SendRemindersButtonProps = {
+ roundId: string
+}
+
+export function SendRemindersButton({ roundId }: SendRemindersButtonProps) {
+ 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 (
+ <>
+ setOpen(true)}>
+
+ Send Reminders
+
+
+
+
+ Send evaluation reminders?
+
+ This will send reminder emails to all jurors who have incomplete evaluations for this round.
+
+
+
+ Cancel
+ mutation.mutate({ roundId })}
+ disabled={mutation.isPending}
+ >
+ {mutation.isPending && }
+ Send Reminders
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/admin/assignment/transfer-assignments-dialog.tsx b/src/components/admin/assignment/transfer-assignments-dialog.tsx
new file mode 100644
index 0000000..8c72607
--- /dev/null
+++ b/src/components/admin/assignment/transfer-assignments-dialog.tsx
@@ -0,0 +1,328 @@
+'use client'
+
+import { useState, useMemo } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Loader2, Sparkles } from 'lucide-react'
+
+export type TransferAssignmentsDialogProps = {
+ roundId: string
+ sourceJuror: { id: string; name: string }
+ open: boolean
+ onClose: () => void
+}
+
+export function TransferAssignmentsDialog({
+ roundId,
+ sourceJuror,
+ open,
+ onClose,
+}: TransferAssignmentsDialogProps) {
+ const utils = trpc.useUtils()
+ const [step, setStep] = useState<1 | 2>(1)
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+
+ // Fetch source juror's assignments
+ const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
+ { roundId },
+ { enabled: open },
+ )
+
+ const jurorAssignments = useMemo(() =>
+ (sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
+ [sourceAssignments, sourceJuror.id],
+ )
+
+ // Fetch transfer candidates when in step 2
+ const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
+ { roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
+ { enabled: step === 2 && selectedIds.size > 0 },
+ )
+
+ // Per-assignment destination overrides
+ const [destOverrides, setDestOverrides] = useState>({})
+ const [forceOverCap, setForceOverCap] = useState(false)
+
+ // Auto-assign: distribute assignments across eligible candidates balanced by load
+ const handleAutoAssign = () => {
+ if (!candidateData) return
+ const movable = candidateData.assignments.filter((a) => a.movable)
+ if (movable.length === 0) return
+
+ // Simulate load starting from each candidate's current load
+ const simLoad = new Map()
+ for (const c of candidateData.candidates) {
+ simLoad.set(c.userId, c.currentLoad)
+ }
+
+ const overrides: Record = {}
+
+ for (const assignment of movable) {
+ const eligible = candidateData.candidates
+ .filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
+
+ if (eligible.length === 0) continue
+
+ // Sort: prefer not-all-completed, then under cap, then lowest simulated load
+ const sorted = [...eligible].sort((a, b) => {
+ // Prefer jurors who haven't completed all evaluations
+ if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
+ const loadA = simLoad.get(a.userId) ?? 0
+ const loadB = simLoad.get(b.userId) ?? 0
+ // Prefer jurors under their cap
+ const overCapA = loadA >= a.cap ? 1 : 0
+ const overCapB = loadB >= b.cap ? 1 : 0
+ if (overCapA !== overCapB) return overCapA - overCapB
+ // Then pick the least loaded
+ return loadA - loadB
+ })
+
+ const best = sorted[0]
+ overrides[assignment.id] = best.userId
+ simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
+ }
+
+ setDestOverrides(overrides)
+ }
+
+ const transferMutation = trpc.assignment.transferAssignments.useMutation({
+ onSuccess: (data) => {
+ utils.assignment.listByStage.invalidate({ roundId })
+ utils.analytics.getJurorWorkload.invalidate({ roundId })
+ utils.roundAssignment.unassignedQueue.invalidate({ roundId })
+ utils.assignment.getReassignmentHistory.invalidate({ roundId })
+
+ const successCount = data.succeeded.length
+ const failCount = data.failed.length
+ if (failCount > 0) {
+ toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
+ } else {
+ toast.success(`Transferred ${successCount} project(s) successfully.`)
+ }
+ onClose()
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Build the transfer plan: for each selected assignment, determine destination
+ const transferPlan = useMemo(() => {
+ if (!candidateData) return []
+ const movable = candidateData.assignments.filter((a) => a.movable)
+ return movable.map((assignment) => {
+ const override = destOverrides[assignment.id]
+ // Default: first eligible candidate
+ const defaultDest = candidateData.candidates.find((c) =>
+ c.eligibleProjectIds.includes(assignment.projectId)
+ )
+ const destId = override || defaultDest?.userId || ''
+ const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
+ return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
+ }).filter((t) => t.destinationJurorId)
+ }, [candidateData, destOverrides])
+
+ // Check if any destination is at or over cap
+ const anyOverCap = useMemo(() => {
+ if (!candidateData) return false
+ const destCounts = new Map()
+ for (const t of transferPlan) {
+ destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
+ }
+ return candidateData.candidates.some((c) => {
+ const extraLoad = destCounts.get(c.userId) ?? 0
+ return c.currentLoad + extraLoad > c.cap
+ })
+ }, [candidateData, transferPlan])
+
+ const handleTransfer = () => {
+ transferMutation.mutate({
+ roundId,
+ sourceJurorId: sourceJuror.id,
+ transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
+ forceOverCap,
+ })
+ }
+
+ const isMovable = (a: any) => {
+ const status = a.evaluation?.status
+ return !status || status === 'NOT_STARTED' || status === 'DRAFT'
+ }
+
+ const movableAssignments = jurorAssignments.filter(isMovable)
+ const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
+
+ return (
+ { if (!v) onClose() }}>
+
+
+ Transfer Assignments from {sourceJuror.name}
+
+ {step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
+
+
+
+ {step === 1 && (
+
+ {loadingAssignments ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : jurorAssignments.length === 0 ? (
+
No assignments found.
+ ) : (
+ <>
+
+ {
+ if (checked) {
+ setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
+ } else {
+ setSelectedIds(new Set())
+ }
+ }}
+ />
+ Select all movable ({movableAssignments.length})
+
+
+ {jurorAssignments.map((a: any) => {
+ const movable = isMovable(a)
+ const status = a.evaluation?.status || 'No evaluation'
+ return (
+
+
{
+ const next = new Set(selectedIds)
+ if (checked) next.add(a.id)
+ else next.delete(a.id)
+ setSelectedIds(next)
+ }}
+ />
+
+
{a.project?.title || 'Unknown'}
+
+
+ {status}
+
+
+ )
+ })}
+
+ >
+ )}
+
+ Cancel
+ { setStep(2); setDestOverrides({}) }}
+ >
+ Next ({selectedIds.size} selected)
+
+
+
+ )}
+
+ {step === 2 && (
+
+ {loadingCandidates ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : !candidateData || candidateData.candidates.length === 0 ? (
+
No eligible candidates found.
+ ) : (
+ <>
+
+
+
+ Auto-assign
+
+
+
+ {candidateData.assignments.filter((a) => a.movable).map((assignment) => {
+ const currentDest = destOverrides[assignment.id] ||
+ candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
+ return (
+
+
+
{assignment.projectTitle}
+
{assignment.evalStatus || 'No evaluation'}
+
+
setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
+ >
+
+
+
+
+ {candidateData.candidates
+ .filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
+ .map((c) => (
+
+ {c.name}
+ ({c.currentLoad}/{c.cap})
+ {c.allCompleted && Done }
+
+ ))}
+
+
+
+ )
+ })}
+
+
+ {transferPlan.length > 0 && (
+
+ Transfer {transferPlan.length} project(s) from {sourceJuror.name}
+
+ )}
+
+ {anyOverCap && (
+
+ setForceOverCap(!!checked)}
+ />
+ Force over-cap: some destinations will exceed their assignment limit
+
+ )}
+ >
+ )}
+
+ setStep(1)}>Back
+
+ {transferMutation.isPending ? : null}
+ Transfer {transferPlan.length} project(s)
+
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/admin/competition/competition-timeline.tsx b/src/components/admin/competition/competition-timeline.tsx
index 25b6eca..44137c7 100644
--- a/src/components/admin/competition/competition-timeline.tsx
+++ b/src/components/admin/competition/competition-timeline.tsx
@@ -6,24 +6,18 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
-import { CheckCircle2, Circle, Clock } from 'lucide-react'
+import {
+ roundTypeConfig as sharedRoundTypeConfig,
+ roundStatusConfig as sharedRoundStatusConfig,
+} from '@/lib/round-config'
-const roundTypeColors: Record = {
- INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
- FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
- EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
- SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
- MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
- LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
- DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
-}
+const roundTypeColors: Record = Object.fromEntries(
+ Object.entries(sharedRoundTypeConfig).map(([k, v]) => [k, `${v.badgeClass} ${v.cardBorder}`])
+)
-const roundStatusConfig: Record = {
- ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
- ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
- ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
- ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
-}
+const roundStatusConfig: Record = Object.fromEntries(
+ Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { icon: v.timelineIcon, color: v.timelineIconColor }])
+)
type RoundSummary = {
id: string
diff --git a/src/components/admin/competition/sections/review-section.tsx b/src/components/admin/competition/sections/review-section.tsx
index 5ec1b55..0943a2a 100644
--- a/src/components/admin/competition/sections/review-section.tsx
+++ b/src/components/admin/competition/sections/review-section.tsx
@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
+import { roundTypeConfig } from '@/lib/round-config'
type WizardRound = {
tempId: string
@@ -40,15 +41,9 @@ type ReviewSectionProps = {
state: WizardState
}
-const roundTypeColors: Record = {
- INTAKE: 'bg-gray-100 text-gray-700',
- FILTERING: 'bg-amber-100 text-amber-700',
- EVALUATION: 'bg-blue-100 text-blue-700',
- SUBMISSION: 'bg-purple-100 text-purple-700',
- MENTORING: 'bg-teal-100 text-teal-700',
- LIVE_FINAL: 'bg-red-100 text-red-700',
- DELIBERATION: 'bg-indigo-100 text-indigo-700',
-}
+const roundTypeColors: Record = Object.fromEntries(
+ Object.entries(roundTypeConfig).map(([k, v]) => [k, v.badgeClass])
+)
export function ReviewSection({ state }: ReviewSectionProps) {
const warnings: string[] = []
diff --git a/src/components/admin/jury/inline-member-cap.tsx b/src/components/admin/jury/inline-member-cap.tsx
new file mode 100644
index 0000000..2c1b6a1
--- /dev/null
+++ b/src/components/admin/jury/inline-member-cap.tsx
@@ -0,0 +1,164 @@
+'use client'
+
+import { useState, useRef, useEffect } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Loader2, Pencil } from 'lucide-react'
+
+export type InlineMemberCapProps = {
+ memberId: string
+ currentValue: number | null
+ onSave: (val: number | null) => void
+ roundId?: string
+ jurorUserId?: string
+}
+
+export function InlineMemberCap({
+ memberId,
+ currentValue,
+ onSave,
+ roundId,
+ jurorUserId,
+}: InlineMemberCapProps) {
+ const utils = trpc.useUtils()
+ const [editing, setEditing] = useState(false)
+ const [value, setValue] = useState(currentValue?.toString() ?? '')
+ const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
+ const [showBanner, setShowBanner] = useState(false)
+ const inputRef = useRef(null)
+
+ const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
+ onSuccess: (data) => {
+ utils.assignment.listByStage.invalidate()
+ utils.analytics.getJurorWorkload.invalidate()
+ utils.roundAssignment.unassignedQueue.invalidate()
+ setShowBanner(false)
+ setOverCapInfo(null)
+ if (data.failed > 0) {
+ toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
+ } else {
+ toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
+ }
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ useEffect(() => {
+ if (editing) inputRef.current?.focus()
+ }, [editing])
+
+ const save = async () => {
+ const trimmed = value.trim()
+ const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
+ if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
+ toast.error('Enter a positive number or leave empty for no cap')
+ return
+ }
+ if (newVal === currentValue) {
+ setEditing(false)
+ return
+ }
+
+ // Check over-cap impact before saving
+ if (newVal !== null && roundId && jurorUserId) {
+ try {
+ const preview = await utils.client.assignment.getOverCapPreview.query({
+ roundId,
+ jurorId: jurorUserId,
+ newCap: newVal,
+ })
+ if (preview.overCapCount > 0) {
+ setOverCapInfo(preview)
+ setShowBanner(true)
+ setEditing(false)
+ return
+ }
+ } catch {
+ // If preview fails, just save the cap normally
+ }
+ }
+
+ onSave(newVal)
+ setEditing(false)
+ }
+
+ const handleRedistribute = () => {
+ const newVal = parseInt(value.trim(), 10)
+ onSave(newVal)
+ if (roundId && jurorUserId) {
+ redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
+ }
+ }
+
+ const handleJustSave = () => {
+ const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
+ onSave(newVal)
+ setShowBanner(false)
+ setOverCapInfo(null)
+ }
+
+ if (showBanner && overCapInfo) {
+ return (
+
+
+
New cap of {value} is below current load ({overCapInfo.total} assignments). {overCapInfo.movableOverCap} can be redistributed.
+ {overCapInfo.immovableOverCap > 0 && (
+
{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.
+ )}
+
+
+ {redistributeMutation.isPending ? : null}
+ Redistribute
+
+
+ Just save cap
+
+ { setShowBanner(false); setOverCapInfo(null) }}>
+ Cancel
+
+
+
+
+ )
+ }
+
+ if (editing) {
+ return (
+ setValue(e.target.value)}
+ onBlur={save}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') save()
+ if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
+ }}
+ />
+ )
+ }
+
+ return (
+ { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
+ >
+ max:
+ {currentValue ?? '\u221E'}
+
+
+ )
+}
diff --git a/src/components/admin/program/competition-settings.tsx b/src/components/admin/program/competition-settings.tsx
new file mode 100644
index 0000000..1d4c904
--- /dev/null
+++ b/src/components/admin/program/competition-settings.tsx
@@ -0,0 +1,165 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Loader2, Save, Settings } from 'lucide-react'
+
+type CompetitionSettingsProps = {
+ competitionId: string
+ initialSettings: {
+ categoryMode: string
+ startupFinalistCount: number
+ conceptFinalistCount: number
+ notifyOnRoundAdvance: boolean
+ notifyOnDeadlineApproach: boolean
+ deadlineReminderDays: number[]
+ }
+}
+
+export function CompetitionSettings({ competitionId, initialSettings }: CompetitionSettingsProps) {
+ const [settings, setSettings] = useState(initialSettings)
+ const [dirty, setDirty] = useState(false)
+
+ const updateMutation = trpc.competition.update.useMutation({
+ onSuccess: () => {
+ toast.success('Competition settings saved')
+ setDirty(false)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ function update(key: K, value: (typeof settings)[K]) {
+ setSettings((prev) => ({ ...prev, [key]: value }))
+ setDirty(true)
+ }
+
+ function handleSave() {
+ updateMutation.mutate({ id: competitionId, ...settings })
+ }
+
+ return (
+
+
+
+
+
+ Competition Settings
+
+
+ Category mode, finalist targets, and notification preferences
+
+
+ {dirty && (
+
+ {updateMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Save Changes
+
+ )}
+
+
+
+
+ Category Mode
+ update('categoryMode', v)}>
+
+
+
+
+ Shared Pool
+ Separate Tracks
+
+
+
+
+ Startup Finalist Count
+ update('startupFinalistCount', parseInt(e.target.value) || 1)}
+ />
+
+
+ Concept Finalist Count
+ update('conceptFinalistCount', parseInt(e.target.value) || 1)}
+ />
+
+
+
+
+
Notifications
+
+
+
Notify on Round Advance
+
Email applicants when their project advances
+
+
update('notifyOnRoundAdvance', v)}
+ />
+
+
+
+
Notify on Deadline Approach
+
Send reminders before deadlines
+
+
update('notifyOnDeadlineApproach', v)}
+ />
+
+
+
Reminder Days Before Deadline
+
+ {settings.deadlineReminderDays.map((day, idx) => (
+
+ {day}d
+ {
+ const next = settings.deadlineReminderDays.filter((_, i) => i !== idx)
+ update('deadlineReminderDays', next)
+ }}
+ >
+ ×
+
+
+ ))}
+ {
+ if (e.key === 'Enter') {
+ const val = parseInt((e.target as HTMLInputElement).value)
+ if (val > 0 && !settings.deadlineReminderDays.includes(val)) {
+ update('deadlineReminderDays', [...settings.deadlineReminderDays, val].sort((a, b) => b - a))
+ ;(e.target as HTMLInputElement).value = ''
+ }
+ }
+ }}
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/round/advance-projects-dialog.tsx b/src/components/admin/round/advance-projects-dialog.tsx
new file mode 100644
index 0000000..c493aae
--- /dev/null
+++ b/src/components/admin/round/advance-projects-dialog.tsx
@@ -0,0 +1,289 @@
+'use client'
+
+import { useState, useMemo } from 'react'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Loader2 } from 'lucide-react'
+
+export type AdvanceProjectsDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ roundId: string
+ roundType?: string
+ projectStates: any[] | undefined
+ config: Record
+ advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
+ competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
+ currentSortOrder?: number
+}
+
+export function AdvanceProjectsDialog({
+ open,
+ onOpenChange,
+ roundId,
+ roundType,
+ projectStates,
+ config,
+ advanceMutation,
+ competitionRounds,
+ currentSortOrder,
+}: AdvanceProjectsDialogProps) {
+ // For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
+ const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
+ // Target round selector
+ const availableTargets = useMemo(() =>
+ (competitionRounds ?? [])
+ .filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
+ .sort((a, b) => a.sortOrder - b.sortOrder),
+ [competitionRounds, currentSortOrder, roundId])
+
+ const [targetRoundId, setTargetRoundId] = useState('')
+
+ // Default to first available target when dialog opens
+ if (open && !targetRoundId && availableTargets.length > 0) {
+ setTargetRoundId(availableTargets[0].id)
+ }
+ const allProjects = projectStates ?? []
+ const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
+ const passedProjects = useMemo(() =>
+ allProjects.filter((ps: any) => ps.state === 'PASSED'),
+ [allProjects])
+
+ 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 = (autoPass?: boolean) => {
+ if (autoPass) {
+ // Auto-pass all pending then advance all
+ advanceMutation.mutate({
+ roundId,
+ autoPassPending: true,
+ ...(targetRoundId ? { targetRoundId } : {}),
+ })
+ } else {
+ const ids = Array.from(selected)
+ if (ids.length === 0) return
+ advanceMutation.mutate({
+ roundId,
+ projectIds: ids,
+ ...(targetRoundId ? { targetRoundId } : {}),
+ })
+ }
+ onOpenChange(false)
+ setSelected(new Set())
+ setTargetRoundId('')
+ }
+
+ const handleClose = () => {
+ onOpenChange(false)
+ setSelected(new Set())
+ setTargetRoundId('')
+ }
+
+ 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) => (
+
+ toggleProject(ps.project?.id)}
+ />
+ {ps.project?.title || 'Untitled'}
+ {ps.project?.teamName && (
+ {ps.project.teamName}
+ )}
+
+ ))}
+
+ )}
+
+ )
+ }
+
+ const totalProjectCount = allProjects.length
+
+ return (
+
+
+
+ Advance Projects
+
+ {isSimpleAdvance
+ ? `Move all ${totalProjectCount} projects to the next round.`
+ : `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
+ }
+
+
+
+ {/* Target round selector */}
+ {availableTargets.length > 0 && (
+
+ Advance to
+
+
+
+
+
+ {availableTargets.map((r) => (
+
+ {r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
+
+ ))}
+
+
+
+ )}
+ {availableTargets.length === 0 && (
+
+ No subsequent rounds found. Projects will advance to the next round by sort order.
+
+ )}
+
+ {isSimpleAdvance ? (
+ /* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
+
+
+
{totalProjectCount}
+
projects will be advanced
+
+ {pendingCount > 0 && (
+
+
+ {pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
+ {passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
+
+
+ )}
+
+ ) : (
+ /* Detailed mode for jury/evaluation rounds — per-project selection */
+
+ {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')}
+
+ )}
+
+
+ Cancel
+ {isSimpleAdvance ? (
+ handleAdvance(true)}
+ disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
+ >
+ {advanceMutation.isPending && }
+ Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
+
+ ) : (
+ handleAdvance()}
+ disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
+ >
+ {advanceMutation.isPending && }
+ Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/admin/round/ai-recommendations-display.tsx b/src/components/admin/round/ai-recommendations-display.tsx
new file mode 100644
index 0000000..1125be2
--- /dev/null
+++ b/src/components/admin/round/ai-recommendations-display.tsx
@@ -0,0 +1,231 @@
+'use client'
+
+import { useState, useMemo } from 'react'
+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 { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Loader2, ChevronDown, CheckCircle2, X } from 'lucide-react'
+
+export type RecommendationItem = {
+ projectId: string
+ rank: number
+ score: number
+ category: string
+ strengths: string[]
+ concerns: string[]
+ recommendation: string
+}
+
+export type AIRecommendationsDisplayProps = {
+ recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
+ projectStates: any[] | undefined
+ roundId: string
+ onClear: () => void
+ onApplied: () => void
+}
+
+export function AIRecommendationsDisplay({
+ recommendations,
+ projectStates,
+ roundId,
+ onClear,
+ onApplied,
+}: AIRecommendationsDisplayProps) {
+ const [expandedId, setExpandedId] = useState(null)
+ const [applying, setApplying] = useState(false)
+
+ // Initialize selected with all recommended project IDs
+ const allRecommendedIds = useMemo(() => {
+ const ids = new Set()
+ for (const item of recommendations.STARTUP) ids.add(item.projectId)
+ for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
+ return ids
+ }, [recommendations])
+
+ const [selectedIds, setSelectedIds] = useState>(() => new Set(allRecommendedIds))
+
+ // Build projectId → title map from projectStates
+ const projectTitleMap = useMemo(() => {
+ const map = new Map()
+ if (projectStates) {
+ for (const ps of projectStates) {
+ if (ps.project?.id && ps.project?.title) {
+ map.set(ps.project.id, ps.project.title)
+ }
+ }
+ }
+ return map
+ }, [projectStates])
+
+ const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
+
+ const toggleProject = (projectId: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(projectId)) next.delete(projectId)
+ else next.add(projectId)
+ return next
+ })
+ }
+
+ const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
+ const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
+
+ const handleApply = async () => {
+ setApplying(true)
+ try {
+ // Transition all selected projects to PASSED
+ const promises = Array.from(selectedIds).map((projectId) =>
+ transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
+ // Project might already be PASSED — that's OK
+ })
+ )
+ await Promise.all(promises)
+ toast.success(`Marked ${selectedIds.size} project(s) as passed`)
+ onApplied()
+ } catch (error) {
+ toast.error('Failed to apply recommendations')
+ } finally {
+ setApplying(false)
+ }
+ }
+
+ 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}`
+ const isSelected = selectedIds.has(item.projectId)
+ const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
+
+ return (
+
+
+
toggleProject(item.projectId)}
+ className="shrink-0"
+ />
+ setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
+ className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
+ >
+
+ {item.rank}
+
+
+
{projectTitle}
+
{item.recommendation}
+
+
+ {item.score}/100
+
+
+
+
+ {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 — {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
+
+
+
+
+ Dismiss
+
+
+
+
+
+
+
+
+ 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')}
+
+
+
+ {/* Apply button */}
+
+
+ {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as Passed
+
+
+ {applying ? (
+ <> Applying...>
+ ) : (
+ <> Apply & Mark as Passed>
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/admin/round/evaluation-criteria-editor.tsx b/src/components/admin/round/evaluation-criteria-editor.tsx
new file mode 100644
index 0000000..ec52403
--- /dev/null
+++ b/src/components/admin/round/evaluation-criteria-editor.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import { useState, useMemo, useCallback } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Loader2 } from 'lucide-react'
+import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
+import type { Criterion } from '@/components/forms/evaluation-form-builder'
+
+export type EvaluationCriteriaEditorProps = {
+ roundId: string
+}
+
+export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
+ const [pendingCriteria, setPendingCriteria] = useState(null)
+ const utils = trpc.useUtils()
+
+ const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
+ { roundId },
+ { refetchInterval: 30_000 },
+ )
+
+ const upsertMutation = trpc.evaluation.upsertForm.useMutation({
+ onSuccess: () => {
+ utils.evaluation.getForm.invalidate({ roundId })
+ toast.success('Evaluation criteria saved')
+ setPendingCriteria(null)
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Convert server criteriaJson to Criterion[] format
+ const serverCriteria: Criterion[] = useMemo(() => {
+ if (!form?.criteriaJson) return []
+ return (form.criteriaJson as Criterion[]).map((c) => {
+ // Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
+ const type = c.type || 'numeric'
+ if (type === 'numeric' && typeof c.scale === 'string') {
+ const parts = (c.scale as string).split('-').map(Number)
+ if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
+ return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
+ }
+ }
+ return { ...c, type } as Criterion
+ })
+ }, [form?.criteriaJson])
+
+ const handleChange = useCallback((criteria: Criterion[]) => {
+ setPendingCriteria(criteria)
+ }, [])
+
+ const handleSave = () => {
+ const criteria = pendingCriteria ?? serverCriteria
+ const validCriteria = criteria.filter((c) => c.label.trim())
+ if (validCriteria.length === 0) {
+ toast.error('Add at least one criterion')
+ return
+ }
+ // Map to upsertForm format
+ upsertMutation.mutate({
+ roundId,
+ criteria: validCriteria.map((c) => ({
+ id: c.id,
+ label: c.label,
+ description: c.description,
+ type: c.type || 'numeric',
+ weight: c.weight,
+ scale: typeof c.scale === 'number' ? c.scale : undefined,
+ minScore: (c as any).minScore,
+ maxScore: (c as any).maxScore,
+ required: c.required,
+ maxLength: c.maxLength,
+ placeholder: c.placeholder,
+ trueLabel: c.trueLabel,
+ falseLabel: c.falseLabel,
+ condition: c.condition,
+ sectionId: c.sectionId,
+ })),
+ })
+ }
+
+ return (
+
+
+
+
+ Evaluation Criteria
+
+ {form
+ ? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
+ : 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
+
+
+ {pendingCriteria && (
+
+ setPendingCriteria(null)}>
+ Cancel
+
+
+ {upsertMutation.isPending && }
+ Save Criteria
+
+
+ )}
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/admin/round/export-evaluations-dialog.tsx b/src/components/admin/round/export-evaluations-dialog.tsx
new file mode 100644
index 0000000..9ed31d0
--- /dev/null
+++ b/src/components/admin/round/export-evaluations-dialog.tsx
@@ -0,0 +1,43 @@
+'use client'
+
+import { useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
+
+export type ExportEvaluationsDialogProps = {
+ roundId: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function ExportEvaluationsDialog({
+ roundId,
+ open,
+ onOpenChange,
+}: ExportEvaluationsDialogProps) {
+ const [exportData, setExportData] = useState(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 (
+
+ )
+}
diff --git a/src/components/admin/round/project-states-table.tsx b/src/components/admin/round/project-states-table.tsx
index 525e4cb..e8a534c 100644
--- a/src/components/admin/round/project-states-table.tsx
+++ b/src/components/admin/round/project-states-table.tsx
@@ -92,7 +92,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const utils = trpc.useUtils()
- const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
+ const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
diff --git a/src/components/admin/round/score-distribution.tsx b/src/components/admin/round/score-distribution.tsx
new file mode 100644
index 0000000..7858693
--- /dev/null
+++ b/src/components/admin/round/score-distribution.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import { useMemo } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { cn } from '@/lib/utils'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+
+export type ScoreDistributionProps = {
+ roundId: string
+}
+
+export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
+ const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery(
+ { roundId },
+ { refetchInterval: 15_000 },
+ )
+
+ const maxCount = useMemo(() =>
+ dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
+ [dist])
+
+ return (
+
+
+ Score Distribution
+
+ {dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 10 }).map((_, i) => )}
+
+ ) : !dist || dist.totalEvaluations === 0 ? (
+
+ No evaluations submitted yet
+
+ ) : (
+
+ {dist.globalDistribution.map((bucket) => {
+ const heightPct = (bucket.count / maxCount) * 100
+ return (
+
+
{bucket.count || ''}
+
+
{bucket.score}
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/admin/rounds/config/config-section-header.tsx b/src/components/admin/rounds/config/config-section-header.tsx
new file mode 100644
index 0000000..bae8627
--- /dev/null
+++ b/src/components/admin/rounds/config/config-section-header.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+
+type CompletionStatus = 'complete' | 'warning' | 'error'
+
+type ConfigSectionHeaderProps = {
+ title: string
+ description?: string
+ status: CompletionStatus
+ summary?: string
+}
+
+const statusDot: Record = {
+ complete: 'bg-emerald-500',
+ warning: 'bg-amber-500',
+ error: 'bg-red-500',
+}
+
+export function ConfigSectionHeader({
+ title,
+ description,
+ status,
+ summary,
+}: ConfigSectionHeaderProps) {
+ return (
+
+
+
+
+
+
{title}
+ {summary && (
+
+ — {summary}
+
+ )}
+
+ {description && (
+
{description}
+ )}
+
+
+
+ )
+}
diff --git a/src/components/dashboard/active-round-panel.tsx b/src/components/dashboard/active-round-panel.tsx
index d400da9..6b900f9 100644
--- a/src/components/dashboard/active-round-panel.tsx
+++ b/src/components/dashboard/active-round-panel.tsx
@@ -3,13 +3,6 @@
import Link from 'next/link'
import { motion } from 'motion/react'
import {
- Inbox,
- Filter,
- ClipboardCheck,
- Upload,
- Users,
- Radio,
- Scale,
Clock,
ArrowRight,
} from 'lucide-react'
@@ -25,6 +18,7 @@ import {
} from '@/components/ui/tooltip'
import { StatusBadge } from '@/components/shared/status-badge'
import { cn, formatEnumLabel, daysUntil } from '@/lib/utils'
+import { roundTypeConfig, projectStateConfig } from '@/lib/round-config'
export type PipelineRound = {
id: string
@@ -60,24 +54,13 @@ type ActiveRoundPanelProps = {
round: PipelineRound
}
-const roundTypeIcons: Record = {
- INTAKE: Inbox,
- FILTERING: Filter,
- EVALUATION: ClipboardCheck,
- SUBMISSION: Upload,
- MENTORING: Users,
- LIVE_FINAL: Radio,
- DELIBERATION: Scale,
-}
+const roundTypeIcons: Record = Object.fromEntries(
+ Object.entries(roundTypeConfig).map(([k, v]) => [k, v.icon])
+)
-const stateColors: Record = {
- PENDING: { bg: 'bg-slate-300', label: 'Pending' },
- IN_PROGRESS: { bg: 'bg-blue-400', label: 'In Progress' },
- PASSED: { bg: 'bg-emerald-500', label: 'Passed' },
- REJECTED: { bg: 'bg-red-400', label: 'Rejected' },
- COMPLETED: { bg: 'bg-[#557f8c]', label: 'Completed' },
- WITHDRAWN: { bg: 'bg-slate-400', label: 'Withdrawn' },
-}
+const stateColors: Record = Object.fromEntries(
+ Object.entries(projectStateConfig).map(([k, v]) => [k, { bg: v.bg, label: v.label }])
+)
function DeadlineCountdown({ date }: { date: Date }) {
const days = daysUntil(date)
@@ -264,7 +247,7 @@ function RoundTypeContent({ round }: { round: PipelineRound }) {
}
export function ActiveRoundPanel({ round }: ActiveRoundPanelProps) {
- const Icon = roundTypeIcons[round.roundType] || ClipboardCheck
+ const Icon = roundTypeIcons[round.roundType] || roundTypeConfig.INTAKE.icon
return (
diff --git a/src/components/dashboard/competition-pipeline.tsx b/src/components/dashboard/competition-pipeline.tsx
index ac80b58..c6eded7 100644
--- a/src/components/dashboard/competition-pipeline.tsx
+++ b/src/components/dashboard/competition-pipeline.tsx
@@ -4,7 +4,7 @@ import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
-import { Workflow, ArrowRight } from 'lucide-react'
+import { Workflow, ArrowRight, ChevronRight } from 'lucide-react'
import {
PipelineRoundNode,
type PipelineRound,
@@ -12,27 +12,46 @@ import {
function Connector({
prevStatus,
+ nextStatus,
index,
}: {
prevStatus: string
+ nextStatus: string
index: number
}) {
const isCompleted =
prevStatus === 'ROUND_CLOSED' || prevStatus === 'ROUND_ARCHIVED'
+ const isNextActive = nextStatus === 'ROUND_ACTIVE'
return (
-
+
)
}
@@ -88,15 +107,17 @@ export function CompetitionPipeline({
-
-
-
+
+ {/* Scrollable container with padding to prevent cutoff */}
+
+
{rounds.map((round, index) => (
-
+
{index < rounds.length - 1 && (
)}
diff --git a/src/components/dashboard/pipeline-round-node.tsx b/src/components/dashboard/pipeline-round-node.tsx
index af4dee9..6d17cf5 100644
--- a/src/components/dashboard/pipeline-round-node.tsx
+++ b/src/components/dashboard/pipeline-round-node.tsx
@@ -5,14 +5,9 @@ import type { Route } from 'next'
import { cn } from '@/lib/utils'
import { motion } from 'motion/react'
import {
- Upload,
- Filter,
- ClipboardCheck,
- FileUp,
- GraduationCap,
- Radio,
- Scale,
-} from 'lucide-react'
+ roundTypeConfig as sharedRoundTypeConfig,
+ roundStatusConfig as sharedRoundStatusConfig,
+} from '@/lib/round-config'
type PipelineRound = {
id: string
@@ -55,66 +50,11 @@ type PipelineRound = {
deliberationCount: number
}
-const roundTypeConfig: Record<
- string,
- { icon: typeof Upload; iconColor: string; iconBg: string }
-> = {
- INTAKE: { icon: Upload, iconColor: 'text-sky-600', iconBg: 'bg-sky-100' },
- FILTERING: {
- icon: Filter,
- iconColor: 'text-amber-600',
- iconBg: 'bg-amber-100',
- },
- EVALUATION: {
- icon: ClipboardCheck,
- iconColor: 'text-violet-600',
- iconBg: 'bg-violet-100',
- },
- SUBMISSION: {
- icon: FileUp,
- iconColor: 'text-blue-600',
- iconBg: 'bg-blue-100',
- },
- MENTORING: {
- icon: GraduationCap,
- iconColor: 'text-teal-600',
- iconBg: 'bg-teal-100',
- },
- LIVE_FINAL: {
- icon: Radio,
- iconColor: 'text-red-600',
- iconBg: 'bg-red-100',
- },
- DELIBERATION: {
- icon: Scale,
- iconColor: 'text-indigo-600',
- iconBg: 'bg-indigo-100',
- },
-}
+const roundTypeConfig = sharedRoundTypeConfig
-const statusStyles: Record<
- string,
- { container: string; label: string }
-> = {
- ROUND_DRAFT: {
- container:
- 'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
- label: 'Draft',
- },
- ROUND_ACTIVE: {
- container:
- 'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
- label: 'Active',
- },
- ROUND_CLOSED: {
- container: 'bg-emerald-50 border-emerald-200 text-emerald-600',
- label: 'Closed',
- },
- ROUND_ARCHIVED: {
- container: 'bg-slate-50/50 border-slate-100 text-slate-300',
- label: 'Archived',
- },
-}
+const statusStyles: Record
= Object.fromEntries(
+ Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { container: v.pipelineContainer, label: v.label }])
+)
function getMetric(round: PipelineRound): string {
const { roundType, projectStates, filteringTotal, filteringPassed, evalTotal, evalSubmitted, assignmentCount, liveSessionStatus, deliberationCount } = round
@@ -147,6 +87,30 @@ function getMetric(round: PipelineRound): string {
}
}
+function getProgressPct(round: PipelineRound): number | null {
+ if (round.status !== 'ROUND_ACTIVE') return null
+
+ switch (round.roundType) {
+ case 'FILTERING': {
+ const processed = round.filteringPassed + round.filteringRejected + round.filteringFlagged
+ const total = round.projectStates.total || round.filteringTotal
+ return total > 0 ? Math.round((processed / total) * 100) : 0
+ }
+ case 'EVALUATION':
+ return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
+ case 'SUBMISSION': {
+ const total = round.projectStates.total
+ return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
+ }
+ case 'MENTORING': {
+ const total = round.projectStates.total
+ return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
+ }
+ default:
+ return null
+ }
+}
+
export function PipelineRoundNode({
round,
index,
@@ -158,7 +122,9 @@ export function PipelineRoundNode({
const Icon = typeConfig.icon
const status = statusStyles[round.status] ?? statusStyles.ROUND_DRAFT
const isActive = round.status === 'ROUND_ACTIVE'
+ const isCompleted = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const metric = getMetric(round)
+ const progressPct = getProgressPct(round)
return (
@@ -185,30 +151,64 @@ export function PipelineRoundNode({
)}
+ {/* Completed check */}
+ {isCompleted && (
+
+
+
+
+
+ )}
+
{/* Icon */}
-
+
{/* Name */}
-
+
{round.name}
+ {/* Type label */}
+
+ {typeConfig.label}
+
+
{/* Status label */}
-
+
{status.label}
+ {/* Progress bar for active rounds */}
+ {progressPct !== null && (
+
+
+
+
+
+ {progressPct}%
+
+
+ )}
+
{/* Metric */}
-
- {metric}
-
+ {progressPct === null && (
+
+ {metric}
+
+ )}
diff --git a/src/components/dashboard/project-list-compact.tsx b/src/components/dashboard/project-list-compact.tsx
index 7454ac7..18b594f 100644
--- a/src/components/dashboard/project-list-compact.tsx
+++ b/src/components/dashboard/project-list-compact.tsx
@@ -13,24 +13,41 @@ import {
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
-import { formatDateOnly, truncate } from '@/lib/utils'
+import { formatDateOnly, truncate, formatRelativeTime } from '@/lib/utils'
-type ProjectListCompactProps = {
- projects: Array<{
- id: string
- title: string
- teamName: string | null
- country: string | null
- competitionCategory: string | null
- oceanIssue: string | null
- logoKey: string | null
- createdAt: Date
- submittedAt: Date | null
- status: string
- }>
+type BaseProject = {
+ id: string
+ title: string
+ teamName: string | null
+ country: string | null
+ competitionCategory: string | null
+ oceanIssue: string | null
+ logoKey: string | null
+ createdAt: Date
+ submittedAt: Date | null
+ status: string
}
-export function ProjectListCompact({ projects }: ProjectListCompactProps) {
+type ActiveProject = BaseProject & {
+ latestEvaluator: string | null
+ latestScore: number | null
+ evaluatedAt: Date | null
+}
+
+type ProjectListCompactProps = {
+ projects: BaseProject[]
+ activeProjects?: ActiveProject[]
+ mode?: 'recent' | 'active'
+}
+
+export function ProjectListCompact({
+ projects,
+ activeProjects,
+ mode = 'recent',
+}: ProjectListCompactProps) {
+ const isActiveMode = mode === 'active' && activeProjects && activeProjects.length > 0
+ const displayProjects = isActiveMode ? activeProjects : projects
+
return (
@@ -40,8 +57,12 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
- Recent Projects
- Latest submissions
+
+ {isActiveMode ? 'Recently Active' : 'Recent Projects'}
+
+
+ {isActiveMode ? 'Latest evaluation activity' : 'Latest submissions'}
+
- {projects.length === 0 ? (
+ {displayProjects.length === 0 ? (
@@ -64,48 +85,69 @@ export function ProjectListCompact({ projects }: ProjectListCompactProps) {
) : (
- {projects.map((project, idx) => (
-
- {
+ const activeProject = isActiveMode ? (project as ActiveProject) : null
+
+ return (
+
-
-
-
-
-
- {truncate(project.title, 50)}
+
+
+
+
+
+
+ {truncate(project.title, 50)}
+
+ {activeProject?.latestScore != null ? (
+
+ {activeProject.latestScore.toFixed(1)}/10
+
+ ) : (
+
+ )}
+
+
+ {isActiveMode && activeProject ? (
+ <>
+ {activeProject.latestEvaluator && (
+ {activeProject.latestEvaluator}
+ )}
+ {activeProject.evaluatedAt && (
+ · {formatRelativeTime(activeProject.evaluatedAt)}
+ )}
+ >
+ ) : (
+ [
+ project.teamName,
+ project.country ? getCountryName(project.country) : null,
+ formatDateOnly(project.submittedAt || project.createdAt),
+ ]
+ .filter(Boolean)
+ .join(' \u00b7 ')
+ )}
-
-
- {[
- project.teamName,
- project.country ? getCountryName(project.country) : null,
- formatDateOnly(project.submittedAt || project.createdAt),
- ]
- .filter(Boolean)
- .join(' \u00b7 ')}
-
-
-
-
- ))}
+
+
+ )
+ })}
)}
diff --git a/src/components/dashboard/round-stats-deliberation.tsx b/src/components/dashboard/round-stats-deliberation.tsx
new file mode 100644
index 0000000..bc9179b
--- /dev/null
+++ b/src/components/dashboard/round-stats-deliberation.tsx
@@ -0,0 +1,99 @@
+'use client'
+
+import { motion } from 'motion/react'
+
+type PipelineRound = {
+ id: string
+ name: string
+ roundType: string
+ status: string
+ projectStates: {
+ PENDING: number
+ IN_PROGRESS: number
+ PASSED: number
+ REJECTED: number
+ COMPLETED: number
+ WITHDRAWN: number
+ total: number
+ }
+ deliberationCount: number
+}
+
+type RoundStatsDeliberationProps = {
+ round: PipelineRound
+}
+
+export function RoundStatsDeliberation({ round }: RoundStatsDeliberationProps) {
+ const { projectStates, deliberationCount } = round
+ const decided = projectStates.PASSED + projectStates.REJECTED
+ const decidedPct = projectStates.total > 0
+ ? ((decided / projectStates.total) * 100).toFixed(0)
+ : '0'
+
+ const stats = [
+ {
+ value: deliberationCount,
+ label: 'Sessions',
+ detail: deliberationCount > 0 ? 'Deliberation sessions' : 'No sessions yet',
+ accent: deliberationCount > 0 ? 'text-brand-blue' : 'text-amber-600',
+ },
+ {
+ value: projectStates.total,
+ label: 'Under review',
+ detail: 'Projects in deliberation',
+ accent: 'text-brand-blue',
+ },
+ {
+ value: decided,
+ label: 'Decided',
+ detail: `${decidedPct}% resolved`,
+ accent: 'text-emerald-600',
+ },
+ {
+ value: projectStates.PENDING,
+ label: 'Pending',
+ detail: projectStates.PENDING > 0 ? 'Awaiting vote' : 'All voted',
+ accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
+ },
+ ]
+
+ return (
+ <>
+
+ {round.name} — Deliberation
+
+
+
+ {stats.map((s, i) => (
+ 0 ? 'border-l border-border/50' : ''}`}>
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+
+
+ {stats.map((s, i) => (
+
+ {s.value}
+ {s.label}
+ {s.detail}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/components/dashboard/round-stats-live-final.tsx b/src/components/dashboard/round-stats-live-final.tsx
new file mode 100644
index 0000000..1860fde
--- /dev/null
+++ b/src/components/dashboard/round-stats-live-final.tsx
@@ -0,0 +1,105 @@
+'use client'
+
+import { motion } from 'motion/react'
+import { formatEnumLabel } from '@/lib/utils'
+
+type PipelineRound = {
+ id: string
+ name: string
+ roundType: string
+ status: string
+ projectStates: {
+ PENDING: number
+ IN_PROGRESS: number
+ PASSED: number
+ REJECTED: number
+ COMPLETED: number
+ WITHDRAWN: number
+ total: number
+ }
+ liveSessionStatus: string | null
+ assignmentCount: number
+}
+
+type RoundStatsLiveFinalProps = {
+ round: PipelineRound
+}
+
+export function RoundStatsLiveFinal({ round }: RoundStatsLiveFinalProps) {
+ const { projectStates, liveSessionStatus, assignmentCount } = round
+ const sessionLabel = liveSessionStatus
+ ? formatEnumLabel(liveSessionStatus)
+ : 'Not started'
+
+ const stats = [
+ {
+ value: projectStates.total,
+ label: 'Presenting',
+ detail: 'Projects in finals',
+ accent: 'text-brand-blue',
+ },
+ {
+ value: sessionLabel,
+ label: 'Session',
+ detail: liveSessionStatus ? 'Live session active' : 'No session yet',
+ accent: liveSessionStatus ? 'text-emerald-600' : 'text-amber-600',
+ isText: true,
+ },
+ {
+ value: projectStates.COMPLETED,
+ label: 'Scored',
+ detail: `${projectStates.total - projectStates.COMPLETED} remaining`,
+ accent: 'text-emerald-600',
+ },
+ {
+ value: assignmentCount,
+ label: 'Jury votes',
+ detail: 'Jury assignments',
+ accent: 'text-brand-teal',
+ },
+ ]
+
+ return (
+ <>
+
+ {round.name} — Live Finals
+
+
+
+ {stats.map((s, i) => (
+ 0 ? 'border-l border-border/50' : ''}`}>
+
+ {s.value}
+
+
{s.label}
+
+ ))}
+
+
+
+
+ {stats.map((s, i) => (
+
+
+ {s.value}
+
+ {s.label}
+ {s.detail}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/components/dashboard/round-stats-mentoring.tsx b/src/components/dashboard/round-stats-mentoring.tsx
new file mode 100644
index 0000000..8df999d
--- /dev/null
+++ b/src/components/dashboard/round-stats-mentoring.tsx
@@ -0,0 +1,99 @@
+'use client'
+
+import { motion } from 'motion/react'
+
+type PipelineRound = {
+ id: string
+ name: string
+ roundType: string
+ status: string
+ projectStates: {
+ PENDING: number
+ IN_PROGRESS: number
+ PASSED: number
+ REJECTED: number
+ COMPLETED: number
+ WITHDRAWN: number
+ total: number
+ }
+ assignmentCount: number
+}
+
+type RoundStatsMentoringProps = {
+ round: PipelineRound
+}
+
+export function RoundStatsMentoring({ round }: RoundStatsMentoringProps) {
+ const { projectStates, assignmentCount } = round
+ const withMentor = projectStates.IN_PROGRESS + projectStates.COMPLETED + projectStates.PASSED
+ const completedPct = projectStates.total > 0
+ ? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
+ : '0'
+
+ const stats = [
+ {
+ value: assignmentCount,
+ label: 'Assignments',
+ detail: 'Mentor-project pairs',
+ accent: 'text-brand-blue',
+ },
+ {
+ value: withMentor,
+ label: 'With mentor',
+ detail: withMentor > 0 ? 'Actively mentored' : 'None assigned',
+ accent: withMentor > 0 ? 'text-emerald-600' : 'text-amber-600',
+ },
+ {
+ value: projectStates.COMPLETED,
+ label: 'Completed',
+ detail: `${completedPct}% done`,
+ accent: 'text-emerald-600',
+ },
+ {
+ value: projectStates.total,
+ label: 'Total',
+ detail: 'Projects in round',
+ accent: 'text-brand-teal',
+ },
+ ]
+
+ return (
+ <>
+
+ {round.name} — Mentoring
+
+
+
+ {stats.map((s, i) => (
+ 0 ? 'border-l border-border/50' : ''}`}>
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+
+
+ {stats.map((s, i) => (
+
+ {s.value}
+ {s.label}
+ {s.detail}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/components/dashboard/round-stats-submission.tsx b/src/components/dashboard/round-stats-submission.tsx
new file mode 100644
index 0000000..4dc5f31
--- /dev/null
+++ b/src/components/dashboard/round-stats-submission.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { motion } from 'motion/react'
+import { daysUntil } from '@/lib/utils'
+
+type PipelineRound = {
+ id: string
+ name: string
+ roundType: string
+ status: string
+ projectStates: {
+ PENDING: number
+ IN_PROGRESS: number
+ PASSED: number
+ REJECTED: number
+ COMPLETED: number
+ WITHDRAWN: number
+ total: number
+ }
+ windowCloseAt: Date | null
+}
+
+type RoundStatsSubmissionProps = {
+ round: PipelineRound
+}
+
+export function RoundStatsSubmission({ round }: RoundStatsSubmissionProps) {
+ const { projectStates } = round
+ const completedPct = projectStates.total > 0
+ ? ((projectStates.COMPLETED / projectStates.total) * 100).toFixed(0)
+ : '0'
+
+ const deadlineDays = round.windowCloseAt ? daysUntil(new Date(round.windowCloseAt)) : null
+ const deadlineLabel =
+ deadlineDays === null
+ ? 'No deadline'
+ : deadlineDays <= 0
+ ? 'Closed'
+ : deadlineDays === 1
+ ? '1 day left'
+ : `${deadlineDays} days left`
+
+ const stats = [
+ {
+ value: projectStates.total,
+ label: 'In round',
+ detail: 'Total projects',
+ accent: 'text-brand-blue',
+ },
+ {
+ value: projectStates.COMPLETED,
+ label: 'Completed',
+ detail: `${completedPct}% done`,
+ accent: 'text-emerald-600',
+ },
+ {
+ value: projectStates.IN_PROGRESS,
+ label: 'In progress',
+ detail: projectStates.IN_PROGRESS > 0 ? 'Working on submissions' : 'None in progress',
+ accent: projectStates.IN_PROGRESS > 0 ? 'text-amber-600' : 'text-emerald-600',
+ },
+ {
+ value: deadlineDays ?? '—',
+ label: 'Deadline',
+ detail: deadlineLabel,
+ accent: deadlineDays !== null && deadlineDays <= 3 ? 'text-red-600' : 'text-brand-teal',
+ },
+ ]
+
+ return (
+ <>
+
+ {round.name} — Submission
+
+
+
+ {stats.map((s, i) => (
+ 0 ? 'border-l border-border/50' : ''}`}>
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+
+
+ {stats.map((s, i) => (
+
+ {s.value}
+ {s.label}
+ {s.detail}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/components/dashboard/round-stats-generic.tsx b/src/components/dashboard/round-stats-summary.tsx
similarity index 66%
rename from src/components/dashboard/round-stats-generic.tsx
rename to src/components/dashboard/round-stats-summary.tsx
index a0474fe..5105732 100644
--- a/src/components/dashboard/round-stats-generic.tsx
+++ b/src/components/dashboard/round-stats-summary.tsx
@@ -2,7 +2,7 @@
import { motion } from 'motion/react'
-type RoundStatsGenericProps = {
+type RoundStatsSummaryProps = {
projectCount: number
newProjectsThisWeek: number
totalJurors: number
@@ -10,52 +10,58 @@ type RoundStatsGenericProps = {
totalAssignments: number
evaluationStats: Array<{ status: string; _count: number }>
actionsCount: number
+ nextDraftRound?: { name: string; roundType: string } | null
}
-export function RoundStatsGeneric({
+export function RoundStatsSummary({
projectCount,
- newProjectsThisWeek,
totalJurors,
activeJurors,
totalAssignments,
evaluationStats,
actionsCount,
-}: RoundStatsGenericProps) {
+ nextDraftRound,
+}: RoundStatsSummaryProps) {
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count ?? 0
const completionPct =
- totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
+ totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '—'
const stats = [
{
value: projectCount,
- label: 'Projects',
- detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
+ label: 'Total projects',
+ detail: 'In this edition',
accent: 'text-brand-blue',
},
{
- value: totalJurors,
- label: 'Jurors',
- detail: `${activeJurors} active`,
+ value: `${activeJurors}/${totalJurors}`,
+ label: 'Jury coverage',
+ detail: totalJurors > 0 ? `${activeJurors} active jurors` : 'No jurors assigned',
accent: 'text-brand-teal',
},
{
- value: `${submittedCount}/${totalAssignments}`,
- label: 'Evaluations',
- detail: `${completionPct}% complete`,
+ value: totalAssignments > 0 ? `${completionPct}%` : '—',
+ label: 'Completion',
+ detail: totalAssignments > 0 ? `${submittedCount}/${totalAssignments} evaluations` : 'No evaluations yet',
accent: 'text-emerald-600',
},
{
value: actionsCount,
label: actionsCount === 1 ? 'Action' : 'Actions',
- detail: actionsCount > 0 ? 'Pending' : 'All clear',
+ detail: nextDraftRound
+ ? `Next: ${nextDraftRound.name}`
+ : actionsCount > 0 ? 'Pending' : 'All clear',
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
},
]
return (
<>
- {/* Mobile: horizontal data strip */}
+
+ No active round — Competition Summary
+
+
- {/* Desktop: editorial stat row */}
{stats.map((s, i) => (
@@ -81,13 +86,9 @@ export function RoundStatsGeneric({
transition={{ duration: 0.3, delay: i * 0.06 }}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
-
- {s.value}
-
+
{s.value}
{s.label}
- {s.detail && (
-
{s.detail}
- )}
+
{s.detail}
))}
diff --git a/src/components/dashboard/round-stats.tsx b/src/components/dashboard/round-stats.tsx
index 04879e7..8dfe01e 100644
--- a/src/components/dashboard/round-stats.tsx
+++ b/src/components/dashboard/round-stats.tsx
@@ -3,7 +3,11 @@
import { RoundStatsIntake } from '@/components/dashboard/round-stats-intake'
import { RoundStatsFiltering } from '@/components/dashboard/round-stats-filtering'
import { RoundStatsEvaluation } from '@/components/dashboard/round-stats-evaluation'
-import { RoundStatsGeneric } from '@/components/dashboard/round-stats-generic'
+import { RoundStatsSubmission } from '@/components/dashboard/round-stats-submission'
+import { RoundStatsMentoring } from '@/components/dashboard/round-stats-mentoring'
+import { RoundStatsLiveFinal } from '@/components/dashboard/round-stats-live-final'
+import { RoundStatsDeliberation } from '@/components/dashboard/round-stats-deliberation'
+import { RoundStatsSummary } from '@/components/dashboard/round-stats-summary'
type PipelineRound = {
id: string
@@ -37,6 +41,7 @@ type PipelineRound = {
type RoundStatsProps = {
activeRound: PipelineRound | null
+ allActiveRounds?: PipelineRound[]
projectCount: number
newProjectsThisWeek: number
totalJurors: number
@@ -44,6 +49,7 @@ type RoundStatsProps = {
totalAssignments: number
evaluationStats: Array<{ status: string; _count: number }>
actionsCount: number
+ nextDraftRound?: { name: string; roundType: string } | null
}
export function RoundStats({
@@ -55,10 +61,11 @@ export function RoundStats({
totalAssignments,
evaluationStats,
actionsCount,
+ nextDraftRound,
}: RoundStatsProps) {
if (!activeRound) {
return (
-
)
}
@@ -89,9 +97,25 @@ export function RoundStats({
activeJurors={activeJurors}
/>
)
+ case 'SUBMISSION':
+ return (
+
+ )
+ case 'MENTORING':
+ return (
+
+ )
+ case 'LIVE_FINAL':
+ return (
+
+ )
+ case 'DELIBERATION':
+ return (
+
+ )
default:
return (
-
)
}
diff --git a/src/lib/round-config.ts b/src/lib/round-config.ts
new file mode 100644
index 0000000..10f8cc9
--- /dev/null
+++ b/src/lib/round-config.ts
@@ -0,0 +1,264 @@
+/**
+ * Shared round type, status, and project state configuration.
+ *
+ * Single source of truth for colors, labels, icons, and descriptions
+ * used across the admin dashboard, round detail, pipeline, and timeline.
+ */
+
+import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
+import {
+ Upload,
+ Filter,
+ ClipboardCheck,
+ FileUp,
+ GraduationCap,
+ Radio,
+ Scale,
+ Inbox,
+ Users,
+ Circle,
+ Clock,
+ CheckCircle2,
+} from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+
+// ── Round Type Configuration ─────────────────────────────────────────────────
+
+export type RoundTypeConfig = {
+ label: string
+ description: string
+ /** Badge classes: bg + text color */
+ badgeClass: string
+ /** Hex dot color for pipeline nodes */
+ dotColor: string
+ /** Lighter variant: bg, text, border classes for cards/filters */
+ cardBg: string
+ cardText: string
+ cardBorder: string
+ /** Icon for pipeline nodes and round detail */
+ icon: LucideIcon
+ iconColor: string
+ iconBg: string
+}
+
+export const roundTypeConfig: Record
= {
+ INTAKE: {
+ label: 'Intake',
+ description: 'Collecting applications',
+ badgeClass: 'bg-gray-100 text-gray-700',
+ dotColor: '#9ca3af',
+ cardBg: 'bg-gray-50',
+ cardText: 'text-gray-600',
+ cardBorder: 'border-gray-300',
+ icon: Inbox,
+ iconColor: 'text-sky-600',
+ iconBg: 'bg-sky-100',
+ },
+ FILTERING: {
+ label: 'Filtering',
+ description: 'AI + manual screening',
+ badgeClass: 'bg-amber-100 text-amber-700',
+ dotColor: '#f59e0b',
+ cardBg: 'bg-amber-50',
+ cardText: 'text-amber-700',
+ cardBorder: 'border-amber-300',
+ icon: Filter,
+ iconColor: 'text-amber-600',
+ iconBg: 'bg-amber-100',
+ },
+ EVALUATION: {
+ label: 'Evaluation',
+ description: 'Jury evaluation & scoring',
+ badgeClass: 'bg-blue-100 text-blue-700',
+ dotColor: '#3b82f6',
+ cardBg: 'bg-blue-50',
+ cardText: 'text-blue-700',
+ cardBorder: 'border-blue-300',
+ icon: ClipboardCheck,
+ iconColor: 'text-violet-600',
+ iconBg: 'bg-violet-100',
+ },
+ SUBMISSION: {
+ label: 'Submission',
+ description: 'Document submission',
+ badgeClass: 'bg-purple-100 text-purple-700',
+ dotColor: '#8b5cf6',
+ cardBg: 'bg-purple-50',
+ cardText: 'text-purple-700',
+ cardBorder: 'border-purple-300',
+ icon: FileUp,
+ iconColor: 'text-blue-600',
+ iconBg: 'bg-blue-100',
+ },
+ MENTORING: {
+ label: 'Mentoring',
+ description: 'Mentor-guided development',
+ badgeClass: 'bg-teal-100 text-teal-700',
+ dotColor: '#557f8c',
+ cardBg: 'bg-teal-50',
+ cardText: 'text-teal-700',
+ cardBorder: 'border-teal-300',
+ icon: GraduationCap,
+ iconColor: 'text-teal-600',
+ iconBg: 'bg-teal-100',
+ },
+ LIVE_FINAL: {
+ label: 'Live Final',
+ description: 'Live presentations & voting',
+ badgeClass: 'bg-red-100 text-red-700',
+ dotColor: '#de0f1e',
+ cardBg: 'bg-red-50',
+ cardText: 'text-red-700',
+ cardBorder: 'border-red-300',
+ icon: Radio,
+ iconColor: 'text-red-600',
+ iconBg: 'bg-red-100',
+ },
+ DELIBERATION: {
+ label: 'Deliberation',
+ description: 'Final jury deliberation',
+ badgeClass: 'bg-indigo-100 text-indigo-700',
+ dotColor: '#6366f1',
+ cardBg: 'bg-indigo-50',
+ cardText: 'text-indigo-700',
+ cardBorder: 'border-indigo-300',
+ icon: Scale,
+ iconColor: 'text-indigo-600',
+ iconBg: 'bg-indigo-100',
+ },
+}
+
+// ── Round Status Configuration ───────────────────────────────────────────────
+
+export type RoundStatusConfig = {
+ label: string
+ description: string
+ /** Badge classes for status badges */
+ bgClass: string
+ /** Dot color class (with optional animation) */
+ dotClass: string
+ /** Hex color for pipeline dot */
+ dotColor: string
+ /** Whether the dot should pulse (active round) */
+ pulse: boolean
+ /** Icon for timeline displays */
+ timelineIcon: LucideIcon
+ timelineIconColor: string
+ /** Container classes for pipeline nodes */
+ pipelineContainer: string
+}
+
+export const roundStatusConfig: Record = {
+ ROUND_DRAFT: {
+ label: 'Draft',
+ description: 'Not yet active. Configure before launching.',
+ bgClass: 'bg-gray-100 text-gray-700',
+ dotClass: 'bg-gray-500',
+ dotColor: '#9ca3af',
+ pulse: false,
+ timelineIcon: Circle,
+ timelineIconColor: 'text-gray-400',
+ pipelineContainer: 'bg-slate-50 border-slate-200 text-slate-400 border-dashed',
+ },
+ ROUND_ACTIVE: {
+ label: 'Active',
+ description: 'Round is live. Projects can be processed.',
+ bgClass: 'bg-emerald-100 text-emerald-700',
+ dotClass: 'bg-emerald-500 animate-pulse',
+ dotColor: '#10b981',
+ pulse: true,
+ timelineIcon: Clock,
+ timelineIconColor: 'text-emerald-500',
+ pipelineContainer: 'bg-blue-50 border-blue-300 text-blue-700 ring-2 ring-blue-400/30 shadow-lg shadow-blue-500/10',
+ },
+ ROUND_CLOSED: {
+ label: 'Closed',
+ description: 'No longer accepting changes. Results are final.',
+ bgClass: 'bg-blue-100 text-blue-700',
+ dotClass: 'bg-blue-500',
+ dotColor: '#3b82f6',
+ pulse: false,
+ timelineIcon: CheckCircle2,
+ timelineIconColor: 'text-blue-500',
+ pipelineContainer: 'bg-emerald-50 border-emerald-200 text-emerald-600',
+ },
+ ROUND_ARCHIVED: {
+ label: 'Archived',
+ description: 'Historical record only.',
+ bgClass: 'bg-muted text-muted-foreground',
+ dotClass: 'bg-muted-foreground',
+ dotColor: '#6b7280',
+ pulse: false,
+ timelineIcon: CheckCircle2,
+ timelineIconColor: 'text-gray-400',
+ pipelineContainer: 'bg-gray-50 border-gray-200 text-gray-400 opacity-60',
+ },
+}
+
+// ── Project Round State Colors ───────────────────────────────────────────────
+
+export type ProjectStateConfig = {
+ label: string
+ /** Background color class for bars and dots */
+ bg: string
+ /** Text color class */
+ text: string
+ /** Badge variant classes */
+ badgeClass: string
+}
+
+export const projectStateConfig: Record = {
+ PENDING: {
+ label: 'Pending',
+ bg: 'bg-slate-300',
+ text: 'text-slate-700',
+ badgeClass: 'bg-gray-100 text-gray-700',
+ },
+ IN_PROGRESS: {
+ label: 'In Progress',
+ bg: 'bg-blue-400',
+ text: 'text-blue-700',
+ badgeClass: 'bg-blue-100 text-blue-700',
+ },
+ PASSED: {
+ label: 'Passed',
+ bg: 'bg-emerald-500',
+ text: 'text-emerald-700',
+ badgeClass: 'bg-emerald-100 text-emerald-700',
+ },
+ REJECTED: {
+ label: 'Rejected',
+ bg: 'bg-red-400',
+ text: 'text-red-700',
+ badgeClass: 'bg-red-100 text-red-700',
+ },
+ COMPLETED: {
+ label: 'Completed',
+ bg: 'bg-[#557f8c]',
+ text: 'text-teal-700',
+ badgeClass: 'bg-teal-100 text-teal-700',
+ },
+ WITHDRAWN: {
+ label: 'Withdrawn',
+ bg: 'bg-slate-400',
+ text: 'text-slate-600',
+ badgeClass: 'bg-orange-100 text-orange-700',
+ },
+}
+
+// ── Award Status Configuration ───────────────────────────────────────────────
+
+export const awardStatusConfig = {
+ DRAFT: { label: 'Draft', color: 'text-gray-500', bgClass: 'bg-gray-100 text-gray-700' },
+ NOMINATIONS_OPEN: { label: 'Nominations Open', color: 'text-amber-600', bgClass: 'bg-amber-100 text-amber-700' },
+ VOTING_OPEN: { label: 'Voting Open', color: 'text-emerald-600', bgClass: 'bg-emerald-100 text-emerald-700' },
+ CLOSED: { label: 'Closed', color: 'text-blue-500', bgClass: 'bg-blue-100 text-blue-700' },
+ ARCHIVED: { label: 'Archived', color: 'text-gray-400', bgClass: 'bg-gray-100 text-gray-600' },
+} as const
+
+// ── Round type option list (for selects/filters) ────────────────────────────
+
+export const ROUND_TYPE_OPTIONS = Object.entries(roundTypeConfig).map(([value, cfg]) => ({
+ value: value as RoundType,
+ label: cfg.label,
+}))
diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts
index 4880a47..bb8cfe9 100644
--- a/src/server/routers/dashboard.ts
+++ b/src/server/routers/dashboard.ts
@@ -109,6 +109,8 @@ export const dashboardRouter = router({
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
+ // Recently active
+ recentlyActiveEvals,
// Action signals
pendingCOIs,
] = await Promise.all([
@@ -257,7 +259,43 @@ export const dashboardRouter = router({
},
}),
- // 17. Pending COIs
+ // 17. Recently active projects (with recent evaluations)
+ ctx.prisma.evaluation.findMany({
+ where: {
+ status: 'SUBMITTED',
+ assignment: {
+ round: { competition: { programId: editionId } },
+ },
+ },
+ orderBy: { submittedAt: 'desc' },
+ take: 8,
+ select: {
+ id: true,
+ globalScore: true,
+ submittedAt: true,
+ assignment: {
+ select: {
+ user: { select: { name: true } },
+ project: {
+ select: {
+ id: true,
+ title: true,
+ teamName: true,
+ country: true,
+ competitionCategory: true,
+ oceanIssue: true,
+ logoKey: true,
+ createdAt: true,
+ submittedAt: true,
+ status: true,
+ },
+ },
+ },
+ },
+ },
+ }),
+
+ // 18. Pending COIs
ctx.prisma.conflictOfInterest.count({
where: {
hasConflict: true,
@@ -443,6 +481,22 @@ export const dashboardRouter = router({
// ── Return ──────────────────────────────────────────────────────
+ // Deduplicate recently active projects (same project may have multiple evals)
+ const seenProjectIds = new Set()
+ const recentlyActiveProjects = recentlyActiveEvals
+ .filter((e) => {
+ const pid = e.assignment.project.id
+ if (seenProjectIds.has(pid)) return false
+ seenProjectIds.add(pid)
+ return true
+ })
+ .map((e) => ({
+ ...e.assignment.project,
+ latestEvaluator: e.assignment.user.name,
+ latestScore: e.globalScore,
+ evaluatedAt: e.submittedAt,
+ }))
+
return {
edition,
// Pipeline
@@ -460,6 +514,7 @@ export const dashboardRouter = router({
pendingCOIs,
// Lists
latestProjects,
+ recentlyActiveProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts
index 4e1fb1f..f7e8c83 100644
--- a/src/server/routers/juryGroup.ts
+++ b/src/server/routers/juryGroup.ts
@@ -96,6 +96,19 @@ export const juryGroupRouter = router({
orderBy: { sortOrder: 'asc' },
include: {
_count: { select: { members: true, assignments: true } },
+ rounds: {
+ select: { id: true, name: true, roundType: true, status: true },
+ orderBy: { sortOrder: 'asc' },
+ },
+ members: {
+ take: 5,
+ orderBy: { joinedAt: 'asc' },
+ select: {
+ id: true,
+ role: true,
+ user: { select: { id: true, name: true, email: true } },
+ },
+ },
},
})
}),