Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:04:15 +01:00
parent 9ab4717f96
commit 6ca39c976b
349 changed files with 69938 additions and 28767 deletions

View File

@@ -76,6 +76,7 @@ import {
ListChecks,
BarChart3,
Loader2,
Bot,
Crown,
UserPlus,
X,
@@ -891,8 +892,8 @@ export default function AwardDetailPage({
</TableCell>
<TableCell>{e.project.country || '-'}</TableCell>
<TableCell>
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs">
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
</Badge>
</TableCell>
{award.useAiEligibility && (

View File

@@ -0,0 +1,170 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, PlayCircle } 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 {
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<string>('')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
id: competitionId,
})
const { data: unassignedQueue, isLoading: isLoadingQueue } =
trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId: selectedRoundId, requiredReviews: 3 },
{ enabled: !!selectedRoundId }
)
const rounds = competition?.rounds || []
const currentRound = rounds.find((r) => r.id === selectedRoundId)
if (isLoadingCompetition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!competition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Competition not found</p>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Competition
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>Choose a round to view and manage assignments</CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{rounds.length === 0 ? (
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
) : (
rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType})
</SelectItem>
))
)}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedRoundId && (
<div className="space-y-6">
<div className="flex justify-end">
<Button onClick={() => setPreviewSheetOpen(true)}>
<PlayCircle className="mr-2 h-4 w-4" />
Generate Assignments
</Button>
</div>
<Tabs defaultValue="coverage" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
</TabsList>
<TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} />
</TabsContent>
<TabsContent value="unassigned" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Unassigned Projects</CardTitle>
<CardDescription>
Projects with fewer than 3 assignments
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingQueue ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : unassignedQueue && unassignedQueue.length > 0 ? (
<div className="space-y-2">
{unassignedQueue.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md"
>
<div>
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.competitionCategory || 'No category'}
</p>
</div>
<div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / 3 assignments
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<AssignmentPreviewSheet
roundId={selectedRoundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,154 @@
'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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Loading...</h1>
</div>
</div>
</div>
);
}
if (!award) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Award Not Found</h1>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold">{award.name}</h1>
<p className="text-muted-foreground">{award.description || 'No description'}</p>
</div>
</div>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="eligible">Eligible Projects</TabsTrigger>
<TabsTrigger value="winners">Winners</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Award Information</CardTitle>
<CardDescription>Configuration and settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Scoring Mode</p>
<Badge variant="outline" className="mt-1">
{award.scoringMode}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">AI Eligibility</p>
<Badge variant="outline" className="mt-1">
{award.useAiEligibility ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Status</p>
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'} className="mt-1">
{award.status}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Program</p>
<p className="mt-1 text-sm">{award.program?.name}</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="eligible" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Eligible Projects</CardTitle>
<CardDescription>
Projects that qualify for this award ({award?.eligibleCount || 0})
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground">
{award?.eligibleCount || 0} eligible projects
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="winners" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Award Winners</CardTitle>
<CardDescription>Selected winners for this award</CardDescription>
</CardHeader>
<CardContent>
{award?.winnerProject ? (
<div className="rounded-lg border p-4">
<div>
<p className="font-medium">{award.winnerProject.title}</p>
<p className="text-sm text-muted-foreground">{award.winnerProject.teamName}</p>
</div>
<Badge className="mt-2">Winner</Badge>
</div>
) : (
<p className="text-center text-muted-foreground">No winner selected yet</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,164 @@
'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: '',
useAiEligibility: false,
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
});
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,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
scoringMode: formData.scoringMode,
useAiEligibility: formData.useAiEligibility
});
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Create Special Award</h1>
<p className="text-muted-foreground">Define a new award for this competition</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Award Details</CardTitle>
<CardDescription>Configure the award properties and eligibility</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Award Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Best Innovation Award"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe the award criteria and purpose"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="useAiEligibility"
checked={formData.useAiEligibility}
onCheckedChange={(checked) =>
setFormData({ ...formData, useAiEligibility: checked as boolean })
}
/>
<Label htmlFor="useAiEligibility" className="font-normal">
Use AI-based eligibility assessment
</Label>
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create Award'}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
);
}

View File

@@ -0,0 +1,104 @@
'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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Special Awards</h1>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Special Awards</h1>
<p className="text-muted-foreground">
Manage special awards and prizes for this competition
</p>
</div>
</div>
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Award
</Button>
</Link>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{awards?.map((award) => (
<Link
key={award.id}
href={`/admin/competitions/${params.competitionId}/awards/${award.id}` as Route}
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<span className="line-clamp-1">{award.name}</span>
</CardTitle>
<CardDescription className="line-clamp-2">
{award.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{award.scoringMode}</Badge>
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'}>
{award.status}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{awards?.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No awards created yet</p>
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
<Button variant="link">Create your first award</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,224 @@
'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 { toast } from 'sonner';
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
import type { Route } from 'next';
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
});
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);
}
});
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Loading...</h1>
</div>
</div>
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Session Not Found</h1>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Deliberation Session</h1>
<Badge>{session.status}</Badge>
</div>
<p className="text-muted-foreground">
{session.round?.name} - {session.category}
</p>
</div>
</div>
<Tabs defaultValue="setup" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="voting">Voting Control</TabsTrigger>
<TabsTrigger value="results">Results</TabsTrigger>
</TabsList>
<TabsContent value="setup" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Session Configuration</CardTitle>
<CardDescription>Deliberation settings and participants</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Mode</p>
<p className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
<p className="mt-1">{session.tieBreakMethod}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Show Collective Rankings
</p>
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{participant.user?.name}</p>
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
</div>
<Badge variant={participant.hasVoted ? 'default' : 'outline'}>
{participant.hasVoted ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="voting" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage the voting window for jury members</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
}
className="flex-1"
>
Open Voting
</Button>
<Button
variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
}
className="flex-1"
>
Close Voting
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Voting Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<span>{participant.user?.name}</span>
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="results" className="space-y-4">
<ResultsPanel sessionId={params.sessionId} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,317 @@
'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 [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 || [];
// TODO: Add getJuryMembers endpoint if needed for participant selection
const juryMembers: any[] = [];
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;
}
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<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
DELIB_VOTING: 'default',
DELIB_TALLYING: 'secondary',
DELIB_LOCKED: 'destructive'
};
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
};
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-8 shrink-0" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
<p className="text-muted-foreground">
Manage final jury deliberations and winner selection
</p>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Session
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{sessions?.map((session: any) => (
<Link
key={session.id}
href={
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
}
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>
{session.round?.name} - {session.category}
</CardTitle>
<CardDescription className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</CardDescription>
</div>
{getStatusBadge(session.status)}
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{session.participants?.length || 0} participants</span>
<span></span>
<span>Tie break: {session.tieBreakMethod}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{sessions?.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No deliberation sessions yet</p>
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
Create your first session
</Button>
</CardContent>
</Card>
)}
{/* Create Session Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Deliberation Session</DialogTitle>
<DialogDescription>
Set up a new deliberation session for final winner selection
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round">Round *</Label>
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
<SelectTrigger id="round">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: any) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
value={formData.category}
onValueChange={(value) =>
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mode">Voting Mode *</Label>
<Select
value={formData.mode}
onValueChange={(value) =>
setFormData({
...formData,
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
})
}
>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreak">Tie Break Method *</Label>
<Select
value={formData.tieBreakMethod}
onValueChange={(value) =>
setFormData({
...formData,
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
})
}
>
<SelectTrigger id="tieBreak">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showCollective"
checked={formData.showCollectiveRankings}
onCheckedChange={(checked) =>
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
}
/>
<Label htmlFor="showCollective" className="font-normal">
Show collective rankings during voting
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPrior"
checked={formData.showPriorJuryData}
onCheckedChange={(checked) =>
setFormData({ ...formData, showPriorJuryData: checked as boolean })
}
/>
<Label htmlFor="showPrior" className="font-normal">
Show prior jury evaluation data
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,139 @@
'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 })
if (isLoading) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!juryGroup) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Jury group not found</p>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to jury groups list">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Juries
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">{juryGroup.name}</h1>
<p className="text-muted-foreground">{juryGroup.slug}</p>
</div>
<Badge variant="secondary" className="text-lg px-4 py-2">
{juryGroup.defaultCapMode}
</Badge>
</div>
<Tabs defaultValue="members" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="members" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Jury Members</CardTitle>
<CardDescription>
Manage the members of this jury group
</CardDescription>
</CardHeader>
<CardContent>
<JuryMembersTable
juryGroupId={juryGroupId}
members={juryGroup.members}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Jury Group Settings</CardTitle>
<CardDescription>
View and edit settings for this jury group
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
<p className="text-base font-medium">{juryGroup.name}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Slug</h3>
<p className="text-base font-medium">{juryGroup.slug}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Default Max Assignments</h3>
<p className="text-base font-medium">{juryGroup.defaultMaxAssignments}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Default Cap Mode</h3>
<Badge variant="secondary">{juryGroup.defaultCapMode}</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Soft Cap Buffer</h3>
<p className="text-base font-medium">{juryGroup.softCapBuffer}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Allow Juror Cap Adjustment</h3>
<Badge variant={juryGroup.allowJurorCapAdjustment ? 'default' : 'secondary'}>
{juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Allow Ratio Adjustment</h3>
<Badge variant={juryGroup.allowJurorRatioAdjustment ? 'default' : 'secondary'}>
{juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Category Quotas Enabled</h3>
<Badge variant={juryGroup.categoryQuotasEnabled ? 'default' : 'secondary'}>
{juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
</Badge>
</div>
</div>
{juryGroup.description && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Description</h3>
<p className="text-base">{juryGroup.description}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,187 @@
'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 (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button
variant="ghost"
onClick={() => router.back()}
className="mb-4"
aria-label="Back to competition details"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Jury Groups</h1>
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Jury Group
</Button>
</div>
{juryGroups && juryGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{juryGroups?.map((group) => (
<Link
key={group.id}
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
<CardHeader>
<CardTitle className="flex items-center justify-between">
{group.name}
<Badge variant="secondary">{group.defaultCapMode}</Badge>
</CardTitle>
<CardDescription>{group.slug}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Members</span>
<span className="font-medium">{group._count.members}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span className="font-medium">{group._count.assignments || 0}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Default Max</span>
<span className="font-medium">{group.defaultMaxAssignments}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Create Jury Group Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. You can add members after creation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="jury-name">Name *</Label>
<Input
id="jury-name"
placeholder="e.g. Main Jury Panel"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jury-description">Description</Label>
<Textarea
id="jury-description"
placeholder="Optional description of this jury group's role"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/competitions/${params.competitionId}` as Route)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Live Finals Control</h1>
<p className="text-muted-foreground">Manage live ceremony presentation and voting</p>
</div>
</div>
<LiveControlPanel roundId={params.roundId} competitionId={params.competitionId} />
</div>
);
}

View File

@@ -0,0 +1,178 @@
'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 { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
const roundTypeColors: Record<string, string> = {
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 RoundDetailPage() {
const params = useParams()
const competitionId = params.competitionId as string
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const utils = trpc.useUtils()
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
// Update local config when round data changes
if (round && !hasChanges) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
}
}
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
},
onError: (err) => toast.error(err.message),
})
const handleConfigChange = (newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}
const handleSave = () => {
updateMutation.mutate({
id: roundId,
configJson: config,
})
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!round) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={`/admin/competitions/${competitionId}` as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested round does not exist
</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={`/admin/competitions/${competitionId}` as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold truncate">{round.name}</h1>
<Badge
variant="secondary"
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="config" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="projects">Projects</TabsTrigger>
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
</TabsList>
{/* Config Tab */}
<TabsContent value="config" className="space-y-4">
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
/>
</TabsContent>
{/* Projects Tab */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* Submission Windows Tab */}
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,531 @@
'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,
FileBox,
ClipboardList,
Settings,
MoreHorizontal,
Archive,
Loader2,
Plus,
} 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<string, string> = {
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.id 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,
})
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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!competition) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={"/admin/competitions" as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Competition Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested competition does not exist
</p>
</div>
</div>
</div>
)
}
const status = competition.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={"/admin/competitions" as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold truncate">{competition.name}</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
config.bgClass,
'hover:opacity-80'
)}
>
{config.label}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
disabled={competition.status === s || updateMutation.isPending}
>
{s.charAt(0) + s.slice(1).toLowerCase()}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm text-muted-foreground font-mono">{competition.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<ClipboardList className="h-4 w-4 mr-2" />
Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/competitions/${competitionId}/deliberation` as Route}>
<Users className="h-4 w-4 mr-2" />
Deliberation
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Archive className="h-4 w-4 mr-2" />
)}
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Rounds</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.rounds.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Juries</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.juryGroups.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<FileBox className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Windows</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.submissionWindows.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Category</span>
</div>
<p className="text-lg font-bold mt-1 truncate">{competition.categoryMode}</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rounds">Rounds</TabsTrigger>
<TabsTrigger value="juries">Juries</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6">
<CompetitionTimeline
competitionId={competitionId}
rounds={competition.rounds}
/>
</TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</div>
{competition.rounds.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured. Add rounds to define the competition flow.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{competition.rounds.map((round, index) => (
<Link
key={round.id}
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{round.name}</p>
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0',
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge
variant="outline"
className="text-[10px] shrink-0 hidden sm:inline-flex"
>
{round.status.replace('ROUND_', '')}
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</TabsContent>
{/* Juries Tab */}
<TabsContent value="juries" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Jury Groups ({competition.juryGroups.length})</h2>
<Link href={`/admin/competitions/${competitionId}/juries` as Route}>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Users className="h-4 w-4 mr-1" />
Manage Juries
</Button>
</Link>
</div>
{competition.juryGroups.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No jury groups configured. Create jury groups to assign evaluators.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{competition.juryGroups.map((group) => (
<Link
key={group.id}
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
>
<Card className="hover:shadow-sm transition-shadow cursor-pointer h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{group.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{group._count.members} members</span>
<Badge variant="outline" className="text-[10px]">
Cap: {group.defaultCapMode}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Category Mode</label>
<p className="text-sm mt-1">{competition.categoryMode}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Startup Finalists</label>
<p className="text-sm mt-1">{competition.startupFinalistCount}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Concept Finalists</label>
<p className="text-sm mt-1">{competition.conceptFinalistCount}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
<div className="flex flex-wrap gap-2 mt-1">
{competition.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
)}
{competition.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
)}
</div>
</div>
</div>
{competition.deadlineReminderDays && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Reminder Days
</label>
<p className="text-sm mt-1">
{(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Add Round Dialog */}
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Round</DialogTitle>
<DialogDescription>
Add a new round to this competition. It will be appended to the current round sequence.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round-name">Name *</Label>
<Input
id="round-name"
placeholder="e.g. Initial Screening"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="round-type">Round Type *</Label>
<Select
value={roundForm.roundType}
onValueChange={(value) => setRoundForm({ ...roundForm, roundType: value })}
>
<SelectTrigger id="round-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,307 @@
'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<string, unknown>
}
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<WizardState>({
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<WizardState>) => {
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href={'/admin/competitions' as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">New Competition</h1>
<p className="text-sm text-muted-foreground">
Create a multi-round competition workflow
</p>
</div>
</div>
{/* Wizard */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSubmit={handleSubmit}
isSubmitting={
createCompetitionMutation.isPending ||
createRoundMutation.isPending ||
createJuryGroupMutation.isPending
}
submitLabel="Create Competition"
canSubmit={canSubmit}
>
<BasicsSection state={state} onChange={handleStateChange} />
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
<JuryGroupsSection
juryGroups={state.juryGroups}
onChange={(juryGroups) => handleStateChange({ juryGroups })}
/>
<ReviewSection state={state} />
</SidebarStepper>
</div>
)
}

View File

@@ -1,201 +1,201 @@
'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,
Layers,
Calendar,
Workflow,
} 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 PipelineListPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view pipelines
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its pipelines
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Pipelines</h1>
<p className="text-sm text-muted-foreground">
Manage evaluation pipelines for {currentEdition?.name}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" />
Create Pipeline
</Button>
</Link>
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pipelines || pipelines.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Workflow className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Pipelines Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Pipelines organize your project evaluation workflow into tracks and stages.
Create your first pipeline to get started with managing project evaluations.
</p>
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Your First Pipeline
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Pipeline Cards */}
{pipelines && pipelines.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pipelines.map((pipeline) => {
const status = pipeline.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
const description = (pipeline.settingsJson as Record<string, unknown> | null)?.description as string | undefined
return (
<Link key={pipeline.id} href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base leading-tight">
{pipeline.name}
</CardTitle>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0 flex items-center gap-1.5',
config.bgClass
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
{config.label}
</Badge>
</div>
{description && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
{description}
</p>
)}
</CardHeader>
<CardContent className="mt-auto">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<span className="font-medium">
{pipeline._count.tracks === 0
? 'No tracks'
: pipeline._count.tracks === 1
? '1 track'
: `${pipeline._count.tracks} tracks`}
</span>
</div>
<span>Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago</span>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}
'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 }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Competitions</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view competitions
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its competitions
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6 px-4 sm:px-0">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold">Competitions</h1>
<p className="text-sm text-muted-foreground">
Manage competitions for {currentEdition?.name}
</p>
</div>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button size="sm" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
New Competition
</Button>
</Link>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!competitions || competitions.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Medal className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Competitions organize your multi-round evaluation workflow with jury groups,
submission windows, and scoring. Create your first competition to get started.
</p>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Your First Competition
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Competition Cards */}
{competitions && competitions.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{competitions.map((competition) => {
const status = competition.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
return (
<Link key={competition.id} href={`/admin/competitions/${competition.id}` as Route}>
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base leading-tight">
{competition.name}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">
{competition.slug}
</p>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0 flex items-center gap-1.5',
config.bgClass
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
{config.label}
</Badge>
</div>
</CardHeader>
<CardContent className="mt-auto">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<span>{competition._count.rounds} rounds</span>
</div>
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
<span>{competition._count.juryGroups} juries</span>
</div>
<div className="flex items-center gap-1.5">
<FileBox className="h-3.5 w-3.5" />
<span>{competition._count.submissionWindows} windows</span>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}

View File

@@ -290,21 +290,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const {
edition,
activeStageCount,
totalStageCount,
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentStages,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
pendingCOIs,
draftStages,
draftRounds,
unassignedProjects,
} = data
@@ -318,25 +318,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
const invitedJurors = totalJurors - activeJurors
// Compute per-stage eval stats
const stagesWithEvalStats = recentStages.map((stage: typeof recentStages[number]) => {
const submitted = stage.assignments.filter(
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round: typeof recentRounds[number]) => {
const submitted = round.assignments.filter(
(a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED'
).length
const total = stage._count.assignments
const total = round._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...stage, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from stages
// Upcoming deadlines from rounds
const now = new Date()
const deadlines: { label: string; stageName: string; date: Date }[] = []
for (const stage of recentStages) {
if (stage.windowCloseAt && new Date(stage.windowCloseAt) > now) {
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.windowCloseAt && new Date(round.windowCloseAt) > now) {
deadlines.push({
label: 'Window closes',
stageName: stage.name,
date: new Date(stage.windowCloseAt),
roundName: round.name,
date: new Date(round.windowCloseAt),
})
}
}
@@ -381,10 +381,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Stages</p>
<p className="text-2xl font-bold mt-1">{totalStageCount}</p>
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeStageCount} active stage{activeStageCount !== 1 ? 's' : ''}
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
@@ -467,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Quick Actions */}
<div className="grid gap-3 sm:grid-cols-3">
<Link href="/admin/rounds/pipelines" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<Link href="/admin/competitions" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium">Pipelines</p>
<p className="text-xs text-muted-foreground">Manage stages & pipelines</p>
<p className="text-sm font-medium">Competitions</p>
<p className="text-xs text-muted-foreground">Manage rounds & competitions</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
@@ -500,7 +500,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Stages Card (enhanced) */}
{/* Rounds Card (enhanced) */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
@@ -510,14 +510,14 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<div className="rounded-lg bg-blue-500/10 p-1.5">
<CircleDot className="h-4 w-4 text-blue-500" />
</div>
Stages
Rounds
</CardTitle>
<CardDescription>
Pipeline stages in {edition.name}
Competition rounds in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds/pipelines"
href="/admin/competitions"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
@@ -525,48 +525,48 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
</CardHeader>
<CardContent>
{stagesWithEvalStats.length === 0 ? (
{roundsWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No stages created yet
No rounds created yet
</p>
<Link
href="/admin/rounds/pipelines"
href="/admin/competitions"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Set up your pipeline
Set up your competition
</Link>
</div>
) : (
<div className="space-y-3">
{stagesWithEvalStats.map((stage) => (
{roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => (
<div
key={stage.id}
key={round.id}
className="block"
>
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{stage.name}</p>
<StatusBadge status={stage.status} />
<p className="font-medium">{round.name}</p>
<StatusBadge status={round.status} />
</div>
<p className="text-sm text-muted-foreground">
{stage._count.projectStageStates} projects &middot; {stage._count.assignments} assignments
{stage.totalEvals > 0 && (
<> &middot; {stage.evalPercent}% evaluated</>
{round._count.projectRoundStates} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{stage.windowOpenAt && stage.windowCloseAt && (
{round.windowOpenAt && round.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Window: {formatDateOnly(stage.windowOpenAt)} &ndash; {formatDateOnly(stage.windowCloseAt)}
Window: {formatDateOnly(round.windowOpenAt)} &ndash; {formatDateOnly(round.windowCloseAt)}
</p>
)}
</div>
</div>
{stage.totalEvals > 0 && (
<Progress value={stage.evalPercent} className="mt-3 h-1.5" gradient />
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</div>
@@ -682,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<CardContent>
<div className="space-y-2">
{pendingCOIs > 0 && (
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
<span className="text-sm">COI declarations to review</span>
@@ -699,16 +699,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
<Badge variant="warning">{unassignedProjects}</Badge>
</Link>
)}
{draftStages > 0 && (
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
{draftRounds > 0 && (
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<CircleDot className="h-4 w-4 text-blue-500" />
<span className="text-sm">Draft stages to activate</span>
<span className="text-sm">Draft rounds to activate</span>
</div>
<Badge variant="secondary">{draftStages}</Badge>
<Badge variant="secondary">{draftRounds}</Badge>
</Link>
)}
{pendingCOIs === 0 && unassignedProjects === 0 && draftStages === 0 && (
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
<div className="flex flex-col items-center py-4 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
@@ -731,7 +731,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</CardTitle>
</CardHeader>
<CardContent>
{stagesWithEvalStats.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0).length === 0 ? (
{roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
@@ -740,19 +740,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
) : (
<div className="space-y-5">
{stagesWithEvalStats
.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0)
.map((stage: typeof stagesWithEvalStats[number]) => (
<div key={stage.id} className="space-y-2">
{roundsWithEvalStats
.filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0)
.map((round: typeof roundsWithEvalStats[number]) => (
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{stage.name}</p>
<p className="text-sm font-medium truncate">{round.name}</p>
<span className="text-sm font-semibold tabular-nums">
{stage.evalPercent}%
{round.evalPercent}%
</span>
</div>
<Progress value={stage.evalPercent} className="h-2" gradient />
<Progress value={round.evalPercent} className="h-2" gradient />
<p className="text-xs text-muted-foreground">
{stage.submittedEvals} of {stage.totalEvals} evaluations submitted
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
@@ -905,7 +905,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.stageName}
{deadline.label} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}

View File

@@ -77,7 +77,7 @@ type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | '
interface Assignment {
projectId: string
stageId: string
roundId: string
}
interface MemberRow {
@@ -271,7 +271,7 @@ export default function MemberInvitePage() {
} | null>(null)
// Pre-assignment state
const [selectedStageId, setSelectedStageId] = useState<string>('')
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
@@ -297,27 +297,27 @@ export default function MemberInvitePage() {
},
})
// Fetch programs with stages for pre-assignment
// Fetch programs with rounds for pre-assignment
const { data: programsData } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeStages: true,
})
// Flatten all stages from all programs
const stages = useMemo(() => {
// Flatten all rounds from all programs
const rounds = useMemo(() => {
if (!programsData) return []
return programsData.flatMap((program) =>
((program.stages ?? []) as Array<{ id: string; name: string }>).map((stage: { id: string; name: string }) => ({
id: stage.id,
name: stage.name,
((program.stages ?? []) as Array<{ id: string; name: string }>).map((round: { id: string; name: string }) => ({
id: round.id,
name: round.name,
programName: `${program.name} ${program.year}`,
}))
)
}, [programsData])
// Fetch projects for selected stage
// Fetch projects for selected round
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
{ stageId: selectedStageId, perPage: 200 },
{ enabled: !!selectedStageId }
{ roundId: selectedRoundId, perPage: 200 },
{ enabled: !!selectedRoundId }
)
const projects = projectsData?.projects || []
@@ -365,7 +365,7 @@ export default function MemberInvitePage() {
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedStageId) return
if (!selectedRoundId) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== rowId) return r
@@ -373,7 +373,7 @@ export default function MemberInvitePage() {
if (existing) {
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
} else {
return { ...r, assignments: [...r.assignments, { projectId, stageId: selectedStageId }] }
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
}
})
)
@@ -599,21 +599,21 @@ export default function MemberInvitePage() {
<div className="flex-1 min-w-0">
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
<p className="text-xs text-muted-foreground">
Select a stage to assign projects to jury members before they onboard
Select a round to assign projects to jury members before they onboard
</p>
</div>
<Select
value={selectedStageId || 'none'}
onValueChange={(v) => setSelectedStageId(v === 'none' ? '' : v)}
value={selectedRoundId || 'none'}
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select stage" />
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No pre-assignment</SelectItem>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
@@ -685,7 +685,7 @@ export default function MemberInvitePage() {
/>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedStageId && (
{row.role === 'JURY_MEMBER' && selectedRoundId && (
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button

View File

@@ -64,12 +64,12 @@ import {
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'STAGE_JURY' | 'PROGRAM_TEAM' | 'USER'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'STAGE_JURY', label: 'Stage Jury' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [stageId, setStageId] = useState('')
const [roundId, setStageId] = useState('')
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
@@ -173,10 +173,10 @@ export default function MessagesPage() {
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'STAGE_JURY': {
if (!stageId) return 'Stage Jury (none selected)'
case 'ROUND_JURY': {
if (!roundId) return 'Stage Jury (none selected)'
const stage = rounds?.find(
(r) => r.id === stageId
(r) => r.id === roundId
)
return stage
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
@@ -217,7 +217,7 @@ export default function MessagesPage() {
toast.error('Please select a role')
return
}
if (recipientType === 'STAGE_JURY' && !stageId) {
if (recipientType === 'ROUND_JURY' && !roundId) {
toast.error('Please select a stage')
return
}
@@ -237,7 +237,7 @@ export default function MessagesPage() {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilter(),
stageId: stageId || undefined,
roundId: roundId || undefined,
subject: subject.trim(),
body: body.trim(),
deliveryChannels,
@@ -332,10 +332,10 @@ export default function MessagesPage() {
</div>
)}
{recipientType === 'STAGE_JURY' && (
{recipientType === 'ROUND_JURY' && (
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={stageId} onValueChange={setStageId}>
<Select value={roundId} onValueChange={setStageId}>
<SelectTrigger>
<SelectValue placeholder="Choose a stage..." />
</SelectTrigger>

View File

@@ -311,7 +311,7 @@ export default function ApplySettingsPage() {
// Initialize local state from server data
useEffect(() => {
if (serverConfig && !initialized) {
setConfig(serverConfig)
setConfig({...DEFAULT_WIZARD_CONFIG, ...serverConfig})
setInitialized(true)
}
}, [serverConfig, initialized])

View File

@@ -1,5 +1,6 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -86,22 +87,22 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Stages</CardTitle>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Pipeline stages for this program
Competition rounds for this program
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
<Link href={`/admin/competitions?programId=${id}` as Route}>
<Plus className="mr-2 h-4 w-4" />
Manage Pipeline
Manage Competitions
</Link>
</Button>
</CardHeader>
<CardContent>
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No stages created yet. Set up a pipeline to get started.
No rounds created yet. Set up a competition to get started.
</div>
) : (
<Table>

View File

@@ -42,14 +42,10 @@ async function ProgramsContent() {
const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
include: {
pipelines: {
competitions: {
include: {
tracks: {
include: {
stages: {
select: { id: true, status: true },
},
},
rounds: {
select: { id: true, status: true },
},
},
},
@@ -57,16 +53,14 @@ async function ProgramsContent() {
orderBy: { createdAt: 'desc' },
})
// Flatten stages per program for convenience
const programsWithStageCounts = programs.map((p) => {
const allStages = p.pipelines.flatMap((pl) =>
pl.tracks.flatMap((t) => t.stages)
)
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
// Flatten rounds per program for convenience
const programsWithRoundCounts = programs.map((p) => {
const allRounds = p.competitions.flatMap((c) => c.rounds)
const activeRounds = allRounds.filter((r) => r.status === 'ROUND_ACTIVE')
return { ...p, roundCount: allRounds.length, activeRoundCount: activeRounds.length }
})
if (programsWithStageCounts.length === 0) {
if (programsWithRoundCounts.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
@@ -102,14 +96,14 @@ async function ProgramsContent() {
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Year</TableHead>
<TableHead>Stages</TableHead>
<TableHead>Rounds</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programsWithStageCounts.map((program) => (
{programsWithRoundCounts.map((program) => (
<TableRow key={program.id}>
<TableCell>
<div>
@@ -124,10 +118,10 @@ async function ProgramsContent() {
<TableCell>{program.year}</TableCell>
<TableCell>
<div>
<p>{program.stageCount} total</p>
{program.activeStageCount > 0 && (
<p>{program.roundCount} total</p>
{program.activeRoundCount > 0 && (
<p className="text-sm text-muted-foreground">
{program.activeStageCount} active
{program.activeRoundCount} active
</p>
)}
</div>
@@ -176,7 +170,7 @@ async function ProgramsContent() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{programsWithStageCounts.map((program) => (
{programsWithRoundCounts.map((program) => (
<Card key={program.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
@@ -193,7 +187,7 @@ async function ProgramsContent() {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Stages</span>
<span>
{program.stageCount} ({program.activeStageCount} active)
{program.roundCount} ({program.activeRoundCount} active)
</span>
</div>
<div className="flex items-center justify-between text-sm">

View File

@@ -18,6 +18,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Bot,
Loader2,
Users,
User,
@@ -201,6 +202,10 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
<Bot className="h-3 w-3" />
AI Recommended
</Badge>
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs

View File

@@ -87,18 +87,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch file requirements from the pipeline's intake stage
const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
{ projectId },
{ enabled: !!project }
)
// Fetch file requirements from the competition's intake round
// Note: This procedure may need to be updated or removed depending on new system
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
// { projectId },
// { enabled: !!project }
// )
const requirementsData = null // Placeholder until procedure is updated
// Fetch available stages for upload selector (if project has a programId)
// Fetch available rounds for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
{ enabled: !!project?.programId }
)
const availableStages = (programData?.stages as Array<{ id: string; name: string }>) || []
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
const utils = trpc.useUtils()
@@ -528,13 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Required Documents from Pipeline Intake Stage */}
{requirementsData && requirementsData.requirements.length > 0 && (
{/* Required Documents from Competition Intake Round */}
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{requirementsData.requirements.map((req, idx) => {
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
const isFulfilled = req.fulfilled
return (
<div
@@ -592,16 +594,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Additional Documents Upload */}
<div>
<p className="text-sm font-semibold mb-3">
{requirementsData && requirementsData.requirements.length > 0
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
? 'Additional Documents'
: 'Upload New Files'}
</p>
<FileUpload
projectId={projectId}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
utils.file.getProjectRequirements.invalidate({ projectId })
// utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</div>
@@ -757,7 +759,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
stageId={assignments[0].stageId}
roundId={assignments[0].roundId}
/>
)}
</div>

View File

@@ -30,9 +30,9 @@ function ImportPageContent() {
const router = useRouter()
const utils = trpc.useUtils()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const roundIdParam = searchParams.get('stage')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Fetch active programs with stages
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
@@ -49,7 +49,7 @@ function ImportPageContent() {
}))
) || []
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
const selectedRound = stages.find((s: { id: string }) => s.id === selectedRoundId)
if (loadingPrograms) {
return <ImportPageSkeleton />
@@ -75,7 +75,7 @@ function ImportPageContent() {
</div>
{/* Stage selection */}
{!selectedStageId && (
{!selectedRoundId && (
<Card>
<CardHeader>
<CardTitle>Select Stage</CardTitle>
@@ -87,17 +87,17 @@ function ImportPageContent() {
{stages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Stages</p>
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a stage first before importing projects
Create a competition with rounds before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
<Link href="/admin/competitions">View Competitions</Link>
</Button>
</div>
) : (
<>
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
@@ -117,11 +117,11 @@ function ImportPageContent() {
<Button
onClick={() => {
if (selectedStageId) {
router.push(`/admin/projects/import?stage=${selectedStageId}`)
if (selectedRoundId) {
router.push(`/admin/projects/import?stage=${selectedRoundId}`)
}
}}
disabled={!selectedStageId}
disabled={!selectedRoundId}
>
Continue
</Button>
@@ -132,14 +132,14 @@ function ImportPageContent() {
)}
{/* Import form */}
{selectedStageId && selectedStage && (
{selectedRoundId && selectedRound && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedStage.name}</p>
<p className="font-medium">Importing into: {selectedRound.name}</p>
<p className="text-sm text-muted-foreground">
{selectedStage.programName}
{selectedRound.programName}
</p>
</div>
<Button
@@ -147,7 +147,7 @@ function ImportPageContent() {
size="sm"
className="ml-auto"
onClick={() => {
setSelectedStageId('')
setSelectedRoundId('')
router.push('/admin/projects/import')
}}
>
@@ -172,8 +172,8 @@ function ImportPageContent() {
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
@@ -182,8 +182,8 @@ function ImportPageContent() {
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
@@ -192,8 +192,8 @@ function ImportPageContent() {
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
programId={selectedRound.programId}
stageName={selectedRound.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()

View File

@@ -85,11 +85,11 @@ const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const roundIdParam = searchParams.get('stage')
const programIdParam = searchParams.get('program')
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Form state
const [title, setTitle] = useState('')
@@ -286,7 +286,7 @@ function NewProjectPageContent() {
<Label>Program *</Label>
<Select value={selectedProgramId} onValueChange={(v) => {
setSelectedProgramId(v)
setSelectedStageId('') // Reset stage on program change
setSelectedRoundId('') // Reset stage on program change
}}>
<SelectTrigger>
<SelectValue placeholder="Select a program" />
@@ -303,7 +303,7 @@ function NewProjectPageContent() {
<div className="space-y-2">
<Label>Stage (optional)</Label>
<Select value={selectedStageId || '__none__'} onValueChange={(v) => setSelectedStageId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
<SelectTrigger>
<SelectValue placeholder="No stage assigned" />
</SelectTrigger>

View File

@@ -53,6 +53,7 @@ import {
Plus,
MoreHorizontal,
ClipboardList,
Bot,
Eye,
Pencil,
FileUp,
@@ -122,7 +123,7 @@ function parseFiltersFromParams(
statuses: searchParams.get('status')
? searchParams.get('status')!.split(',')
: [],
stageId: searchParams.get('stage') || '',
roundId: searchParams.get('round') || '',
competitionCategory: searchParams.get('category') || '',
oceanIssue: searchParams.get('issue') || '',
country: searchParams.get('country') || '',
@@ -156,7 +157,7 @@ function filtersToParams(
if (filters.search) params.set('q', filters.search)
if (filters.statuses.length > 0)
params.set('status', filters.statuses.join(','))
if (filters.stageId) params.set('stage', filters.stageId)
if (filters.roundId) params.set('round', filters.roundId)
if (filters.competitionCategory)
params.set('category', filters.competitionCategory)
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
@@ -181,7 +182,7 @@ export default function ProjectsPage() {
const [filters, setFilters] = useState<ProjectFilters>({
search: parsed.search,
statuses: parsed.statuses,
stageId: parsed.stageId,
roundId: parsed.roundId,
competitionCategory: parsed.competitionCategory,
oceanIssue: parsed.oceanIssue,
country: parsed.country,
@@ -252,7 +253,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -284,14 +285,14 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
// Assign to stage dialog state
// Assign to round dialog state
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignStageId, setAssignStageId] = useState('')
const [assignRoundId, setAssignRoundId] = useState('')
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
@@ -351,10 +352,10 @@ export default function ProjectsPage() {
: null
const handleStartTagging = () => {
if (taggingScope === 'stage' && selectedStageForTagging) {
// Router only accepts programId; resolve from the selected stage's parent program
if (taggingScope === 'round' && selectedRoundForTagging) {
// Router only accepts programId; resolve from the selected round's parent program
const parentProgram = programs?.find((p) =>
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedRoundForTagging)
)
if (parentProgram) {
startTaggingJob.mutate({ programId: parentProgram.id })
@@ -368,19 +369,19 @@ export default function ProjectsPage() {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setActiveTaggingJobId(null)
setSelectedStageForTagging('')
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
// Get selected program's rounds (flattened from pipelines -> tracks -> stages)
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programStages = selectedProgram?.stages ?? []
const programRounds = selectedProgram?.stages ?? []
// Calculate stats for display
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
: (selectedRoundForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedRoundForTagging)) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
@@ -394,7 +395,7 @@ export default function ProjectsPage() {
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
@@ -415,7 +416,7 @@ export default function ProjectsPage() {
| 'REJECTED'
>)
: undefined,
stageId: filters.stageId || undefined,
roundId: filters.roundId || undefined,
competitionCategory:
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
undefined,
@@ -475,12 +476,12 @@ export default function ProjectsPage() {
}
)
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to round`)
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkAssignStageId('')
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
@@ -576,13 +577,13 @@ export default function ProjectsPage() {
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
: false
const assignToStage = trpc.projectPool.assignToStage.useMutation({
const assignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: () => {
toast.success('Project assigned to stage')
toast.success('Project assigned to round')
utils.project.list.invalidate()
setAssignDialogOpen(false)
setProjectToAssign(null)
setAssignStageId('')
setAssignRoundId('')
},
onError: (error) => {
toast.error(error.message || 'Failed to assign project')
@@ -618,7 +619,7 @@ export default function ProjectsPage() {
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Tags className="mr-2 h-4 w-4" />
<Bot className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
@@ -668,7 +669,7 @@ export default function ProjectsPage() {
filters={filters}
filterOptions={filterOptions ? {
...filterOptions,
stages: programs?.flatMap(p =>
rounds: programs?.flatMap(p =>
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
id: s.id,
name: s.name,
@@ -799,7 +800,7 @@ export default function ProjectsPage() {
<p className="text-sm text-muted-foreground">
{filters.search ||
filters.statuses.length > 0 ||
filters.stageId ||
filters.roundId ||
filters.competitionCategory ||
filters.oceanIssue ||
filters.country
@@ -992,7 +993,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1132,7 +1133,7 @@ export default function ProjectsPage() {
}}
>
<FolderOpen className="mr-2 h-4 w-4" />
Assign to Stage
Assign to Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -1258,10 +1259,10 @@ export default function ProjectsPage() {
onClick={() => setBulkAssignDialogOpen(true)}
>
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Stage
Assign to Round
</Button>
{/* Change Status (only when filtered by stage) */}
{filters.stageId && (
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
@@ -1442,22 +1443,22 @@ export default function ProjectsPage() {
</AlertDialogContent>
</AlertDialog>
{/* Assign to Stage Dialog */}
{/* Assign to Round Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) { setProjectToAssign(null); setAssignStageId('') }
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign &quot;{projectToAssign?.title}&quot; to a stage.
Assign &quot;{projectToAssign?.title}&quot; to a round.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={assignStageId} onValueChange={setAssignStageId}>
<Label>Select Round</Label>
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
@@ -1479,40 +1480,40 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (projectToAssign && assignStageId) {
assignToStage.mutate({
if (projectToAssign && assignRoundId) {
assignToRound.mutate({
projectIds: [projectToAssign.id],
stageId: assignStageId,
roundId: assignRoundId,
})
}
}}
disabled={!assignStageId || assignToStage.isPending}
disabled={!assignRoundId || assignToRound.isPending}
>
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Assign to Stage Dialog */}
{/* Bulk Assign to Round Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignStageId('')
if (!open) setBulkAssignRoundId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Stage</DialogTitle>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to &quot;Assigned&quot;.
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a stage..." />
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap((p) =>
@@ -1532,16 +1533,16 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={() => {
if (bulkAssignStageId && selectedIds.size > 0) {
bulkAssignToStage.mutate({
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
projectIds: Array.from(selectedIds),
stageId: bulkAssignStageId,
roundId: bulkAssignRoundId,
})
}
}}
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
>
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
@@ -1718,19 +1719,19 @@ export default function ProjectsPage() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('stage')}
onClick={() => setTaggingScope('round')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'stage'
taggingScope === 'round'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
Single Stage
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific stage
Tag projects in one specific round
</span>
</button>
<button
@@ -1747,7 +1748,7 @@ export default function ProjectsPage() {
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all stages
Tag all projects across all rounds
</span>
</button>
</div>
@@ -1755,15 +1756,15 @@ export default function ProjectsPage() {
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'stage' ? (
{taggingScope === 'round' ? (
<>
<Label htmlFor="stage-select">Select Stage</Label>
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedStageForTagging}
onValueChange={setSelectedStageForTagging}
value={selectedRoundForTagging}
onValueChange={setSelectedRoundForTagging}
>
<SelectTrigger id="stage-select">
<SelectValue placeholder="Choose a stage..." />
<SelectTrigger id="round-select">
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{programs?.flatMap(p =>
@@ -1828,7 +1829,7 @@ export default function ProjectsPage() {
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'stage' && !selectedStageForTagging) ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>

View File

@@ -58,17 +58,17 @@ export default function ProjectPoolPage() {
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToStage.useMutation({
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.program.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetStageId('')
refetch()
},
onError: (error) => {
onError: (error: any) => {
toast.error(error.message || 'Failed to assign projects')
},
})
@@ -77,14 +77,14 @@ export default function ProjectPoolPage() {
if (selectedProjects.length === 0 || !targetStageId) return
assignMutation.mutate({
projectIds: selectedProjects,
stageId: targetStageId,
roundId: targetStageId,
})
}
const handleQuickAssign = (projectId: string, stageId: string) => {
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
stageId,
roundId,
})
}
@@ -242,7 +242,7 @@ export default function ProjectPoolPage() {
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">

View File

@@ -63,7 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
export interface ProjectFilters {
search: string
statuses: string[]
stageId: string
roundId: string
competitionCategory: string
oceanIssue: string
country: string
@@ -76,7 +76,7 @@ export interface FilterOptions {
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
rounds?: Array<{ id: string; name: string; programName: string; programYear: number }>
}
interface ProjectFiltersBarProps {
@@ -94,7 +94,7 @@ export function ProjectFiltersBar({
const activeFilterCount = [
filters.statuses.length > 0,
filters.stageId !== '',
filters.roundId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
@@ -114,7 +114,7 @@ export function ProjectFiltersBar({
onChange({
search: filters.search,
statuses: [],
stageId: '',
roundId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
@@ -175,19 +175,19 @@ export function ProjectFiltersBar({
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Stage / Edition</Label>
<Label className="text-sm">Round / Edition</Label>
<Select
value={filters.stageId || '_all'}
value={filters.roundId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, stageId: v === '_all' ? '' : v })
onChange({ ...filters, roundId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All stages" />
<SelectValue placeholder="All rounds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All stages</SelectItem>
{filterOptions?.stages?.map((s) => (
<SelectItem value="_all">All rounds</SelectItem>
{filterOptions?.rounds?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.programYear} {s.programName})
</SelectItem>

View File

@@ -77,7 +77,7 @@ function ReportsOverview() {
}
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery(
@@ -107,7 +107,7 @@ function ReportsOverview() {
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
const totalProjects = dashStats?.projectCount ?? 0
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0
@@ -400,11 +400,11 @@ function ReportsOverview() {
)
}
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { stageId: value }
return { roundId: value }
}
function StageAnalytics() {
@@ -423,7 +423,7 @@ function StageAnalytics() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -464,11 +464,11 @@ function StageAnalytics() {
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
)
if (roundsLoading) {
@@ -613,19 +613,19 @@ function CrossStageTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
@@ -646,20 +646,20 @@ function CrossStageTab() {
<CardContent>
<div className="flex flex-wrap gap-2">
{stages.map((stage) => {
const isSelected = selectedStageIds.includes(stage.id)
const isSelected = selectedRoundIds.includes(stage.id)
return (
<Badge
key={stage.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleStage(stage.id)}
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
</Badge>
)
})}
</div>
{selectedStageIds.length < 2 && (
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
@@ -668,7 +668,7 @@ function CrossStageTab() {
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedStageIds.length >= 2 && (
{comparisonLoading && selectedRoundIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
@@ -680,8 +680,8 @@ function CrossStageTab() {
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
stageId: string
stageName: string
roundId: string
roundName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -707,7 +707,7 @@ function JurorConsistencyTab() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -779,7 +779,7 @@ function DiversityTab() {
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -882,7 +882,7 @@ export default function ReportsPage() {
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<Layers className="h-4 w-4" />
By Pipeline
By Round
</Link>
</TabsTrigger>
</TabsList>
@@ -901,7 +901,7 @@ export default function ReportsPage() {
</Select>
{pdfStageId && (
<ExportPdfButton
stageId={pdfStageId}
roundId={pdfStageId}
roundName={selectedPdfStage?.name}
programName={selectedPdfStage?.programName}
/>

View File

@@ -1,671 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
BarChart3,
Layers,
Users,
CheckCircle2,
Target,
Trophy,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const stateColors: Record<string, string> = {
PENDING: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
PASSED: 'bg-emerald-100 text-emerald-700',
REJECTED: 'bg-red-100 text-red-700',
COMPLETED: 'bg-emerald-100 text-emerald-700',
ELIMINATED: 'bg-red-100 text-red-700',
WAITING: 'bg-amber-100 text-amber-700',
}
const awardStatusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
NOMINATIONS_OPEN: 'bg-blue-100 text-blue-700',
VOTING_OPEN: 'bg-amber-100 text-amber-700',
CLOSED: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-gray-100 text-gray-500',
}
// ─── Stages Tab Content ────────────────────────────────────────────────────
function StagesTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedStageId, setSelectedStageId] = useState<string>('')
const { data: overview, isLoading: overviewLoading } =
trpc.analytics.getStageCompletionOverview.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: scoreDistribution, isLoading: scoresLoading } =
trpc.analytics.getStageScoreDistribution.useQuery(
{ stageId: selectedStageId },
{ enabled: !!selectedStageId }
)
if (overviewLoading) {
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!overview) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="py-5 text-center">
<Layers className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalStages}</p>
<p className="text-xs text-muted-foreground">Total Stages</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Target className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalProjects}</p>
<p className="text-xs text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<Users className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalAssignments}</p>
<p className="text-xs text-muted-foreground">Total Assignments</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-5 text-center">
<CheckCircle2 className="h-5 w-5 text-emerald-600 mx-auto mb-1" />
<p className="text-2xl font-bold">{overview.summary.totalCompleted}</p>
<p className="text-xs text-muted-foreground">Evaluations Completed</p>
</CardContent>
</Card>
</div>
{/* Stage overview table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage Completion Overview</CardTitle>
<CardDescription>
Click a stage to see detailed score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Track</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Assignments</TableHead>
<TableHead className="text-center">Completed</TableHead>
<TableHead className="text-center">Jurors</TableHead>
<TableHead>Progress</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.stages.map((stage) => (
<TableRow
key={stage.stageId}
className={cn(
'cursor-pointer transition-colors',
selectedStageId === stage.stageId && 'bg-brand-blue/5 dark:bg-brand-teal/5'
)}
onClick={() => setSelectedStageId(stage.stageId)}
>
<TableCell className="font-medium">{stage.stageName}</TableCell>
<TableCell className="text-muted-foreground text-sm">{stage.trackName}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalProjects}</TableCell>
<TableCell className="text-center tabular-nums">{stage.totalAssignments}</TableCell>
<TableCell className="text-center tabular-nums">{stage.completedEvaluations}</TableCell>
<TableCell className="text-center tabular-nums">{stage.jurorCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[120px]">
<Progress value={stage.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{stage.completionRate}%
</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* State breakdown per stage */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project State Breakdown</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{overview.stages.map((stage) => (
<div key={stage.stageId} className="space-y-2">
<p className="text-sm font-medium">{stage.stageName}</p>
<div className="flex flex-wrap gap-2">
{stage.stateBreakdown.map((sb) => (
<Badge
key={sb.state}
variant="outline"
className={cn('text-xs', stateColors[sb.state])}
>
{sb.state}: {sb.count}
</Badge>
))}
{stage.stateBreakdown.length === 0 && (
<span className="text-xs text-muted-foreground">No projects</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Score distribution for selected stage */}
{selectedStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Score Distribution
{overview.stages.find((s) => s.stageId === selectedStageId) && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{overview.stages.find((s) => s.stageId === selectedStageId)?.stageName}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{scoresLoading ? (
<Skeleton className="h-48 w-full" />
) : scoreDistribution ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.averageGlobalScore.toFixed(1)}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{scoreDistribution.criterionDistributions.length}
</p>
<p className="text-xs text-muted-foreground">Criteria</p>
</div>
</div>
{/* Score histogram (text-based) */}
<div className="space-y-2">
<p className="text-sm font-medium">Global Score Distribution</p>
{scoreDistribution.globalDistribution.map((bucket) => {
const maxCount = Math.max(
...scoreDistribution.globalDistribution.map((b) => b.count),
1
)
const width = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex items-center gap-2">
<span className="text-xs tabular-nums w-4 text-right">{bucket.score}</span>
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden">
<div
className="h-full bg-brand-blue/60 dark:bg-brand-teal/60 rounded transition-all"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-xs tabular-nums w-6 text-right">{bucket.count}</span>
</div>
)
})}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Awards Tab Content ────────────────────────────────────────────────────
function AwardsTabContent({
activePipelineId,
}: {
activePipelineId: string
}) {
const [selectedAwardStageId, setSelectedAwardStageId] = useState<string>('')
const { data: awards, isLoading: awardsLoading } =
trpc.analytics.getAwardSummary.useQuery(
{ pipelineId: activePipelineId },
{ enabled: !!activePipelineId }
)
const { data: voteDistribution, isLoading: votesLoading } =
trpc.analytics.getAwardVoteDistribution.useQuery(
{ stageId: selectedAwardStageId },
{ enabled: !!selectedAwardStageId }
)
if (awardsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!awards || awards.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No award tracks</p>
<p className="text-sm text-muted-foreground mt-1">
This pipeline has no award tracks configured.
</p>
</CardContent>
</Card>
)
}
return (
<>
{/* Awards summary table */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Trophy className="h-5 w-5" />
Award Tracks
</CardTitle>
<CardDescription>
Click an award&apos;s stage to see vote/score distribution
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Award</TableHead>
<TableHead>Status</TableHead>
<TableHead>Scoring</TableHead>
<TableHead>Routing</TableHead>
<TableHead className="text-center">Projects</TableHead>
<TableHead className="text-center">Completion</TableHead>
<TableHead>Winner</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{awards.map((award) => (
<TableRow key={award.trackId}>
<TableCell className="font-medium">{award.awardName}</TableCell>
<TableCell>
{award.awardStatus ? (
<Badge
variant="outline"
className={cn('text-xs', awardStatusColors[award.awardStatus])}
>
{award.awardStatus.replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.scoringMode ? (
<Badge variant="outline" className="text-xs capitalize">
{award.scoringMode.toLowerCase().replace(/_/g, ' ')}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{award.routingMode ? (
<Badge variant="outline" className="text-xs">
{award.routingMode}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-center tabular-nums">{award.projectCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[100px]">
<Progress value={award.completionRate} className="h-2 flex-1" />
<span className="text-xs tabular-nums font-medium w-8 text-right">
{award.completionRate}%
</span>
</div>
</TableCell>
<TableCell>
{award.winner ? (
<div className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-amber-500 shrink-0" />
<span className="text-sm font-medium truncate max-w-[150px]">
{award.winner.title}
</span>
{award.winner.overridden && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
overridden
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">Not finalized</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Award stages clickable list */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Award Stage Details</CardTitle>
<CardDescription>
Select a stage to view vote and score distribution
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{awards.map((award) =>
award.stages.map((stage) => (
<button
key={stage.id}
onClick={() => setSelectedAwardStageId(stage.id)}
className={cn(
'w-full text-left rounded-lg border p-3 transition-colors',
selectedAwardStageId === stage.id
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
: 'hover:bg-muted/50'
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{award.awardName} {stage.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Type: {stage.stageType.toLowerCase().replace(/_/g, ' ')} | Status: {stage.status}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
</Badge>
</div>
</button>
))
)}
</div>
</CardContent>
</Card>
{/* Vote/score distribution for selected award stage */}
{selectedAwardStageId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Vote / Score Distribution
</CardTitle>
</CardHeader>
<CardContent>
{votesLoading ? (
<Skeleton className="h-48 w-full" />
) : voteDistribution && voteDistribution.projects.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalEvaluations}
</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold tabular-nums">
{voteDistribution.totalVotes}
</p>
<p className="text-xs text-muted-foreground">Award Votes</p>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead className="text-center">Evals</TableHead>
<TableHead className="text-center">Votes</TableHead>
<TableHead className="text-center">Avg Score</TableHead>
<TableHead className="text-center">Min</TableHead>
<TableHead className="text-center">Max</TableHead>
<TableHead className="text-center">Avg Rank</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{voteDistribution.projects.map((project) => (
<TableRow key={project.projectId}>
<TableCell className="font-medium truncate max-w-[200px]">
{project.title}
</TableCell>
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
{project.teamName || '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.evaluationCount}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.voteCount}
</TableCell>
<TableCell className="text-center tabular-nums font-medium">
{project.avgScore !== null ? project.avgScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.minScore !== null ? project.minScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.maxScore !== null ? project.maxScore.toFixed(1) : '—'}
</TableCell>
<TableCell className="text-center tabular-nums">
{project.avgRank !== null ? project.avgRank.toFixed(1) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground">No vote/score data available for this stage.</p>
)}
</CardContent>
</Card>
)}
</>
)
}
// ─── Main Page ─────────────────────────────────────────────────────────────
export default function StageAnalyticsReportsPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? ''
const [selectedPipelineId, setSelectedPipelineId] = useState<string>('')
// Fetch pipelines
const { data: pipelines, isLoading: pipelinesLoading } =
trpc.pipeline.list.useQuery(
{ programId },
{ enabled: !!programId }
)
// Auto-select first pipeline
const activePipelineId = selectedPipelineId || (pipelines?.[0]?.id ?? '')
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/admin/reports" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Layers className="h-6 w-6" />
Pipeline Analytics
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Stage-level reporting for the pipeline system
</p>
</div>
</div>
{/* Pipeline selector */}
{pipelines && pipelines.length > 0 && (
<Select
value={activePipelineId}
onValueChange={(v) => {
setSelectedPipelineId(v)
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Select pipeline" />
</SelectTrigger>
<SelectContent>
{pipelines.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{pipelinesLoading ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
) : !activePipelineId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No pipeline data</p>
<p className="text-sm text-muted-foreground mt-1">
Select a pipeline to view analytics.
</p>
</CardContent>
</Card>
) : (
<Tabs defaultValue="stages" className="space-y-6">
<TabsList>
<TabsTrigger value="stages" className="gap-2">
<Layers className="h-4 w-4" />
Stages
</TabsTrigger>
<TabsTrigger value="awards" className="gap-2">
<Trophy className="h-4 w-4" />
Awards
</TabsTrigger>
</TabsList>
<TabsContent value="stages" className="space-y-6">
<StagesTabContent activePipelineId={activePipelineId} />
</TabsContent>
<TabsContent value="awards" className="space-y-6">
<AwardsTabContent activePipelineId={activePipelineId} />
</TabsContent>
</Tabs>
)}
</div>
)
}

View File

@@ -1,352 +0,0 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
import type { StepConfig } from '@/components/ui/sidebar-stepper'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import { useEdition } from '@/contexts/edition-context'
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
export default function NewPipelinePage() {
const router = useRouter()
const searchParams = useSearchParams()
const { currentEdition } = useEdition()
const programId = searchParams.get('programId') || currentEdition?.id || ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [currentStep, setCurrentStep] = useState(0)
const initialStateRef = useRef(JSON.stringify(state))
// Update programId in state when edition context loads
useEffect(() => {
if (programId && !state.programId) {
setState((prev) => ({ ...prev, programId }))
}
}, [programId, state.programId])
// Dirty tracking — warn on navigate away
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (JSON.stringify(state) !== initialStateRef.current) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [state])
const updateState = useCallback((updates: Partial<WizardState>) => {
setState((prev) => ({ ...prev, ...updates }))
}, [])
// Get stage configs from the main track
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
const updateStageConfig = useCallback(
(stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => ({
...prev,
tracks: prev.tracks.map((track) => {
if (track.kind !== 'MAIN') return track
return {
...track,
stages: track.stages.map((stage) =>
stage.stageType === stageType ? { ...stage, configJson } : stage
),
}
}),
}))
},
[]
)
const updateMainTrackStages = useCallback(
(stages: WizardState['tracks'][0]['stages']) => {
setState((prev) => ({
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}))
},
[]
)
// Validation
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
// Mutations
const createMutation = trpc.pipeline.createStructure.useMutation({
onSuccess: (data) => {
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
toast.success('Pipeline created successfully')
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
},
onError: (err) => {
toast.error(err.message)
},
})
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => {
toast.success('Pipeline published successfully')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = async (publish: boolean) => {
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
// Navigate to first section with errors
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
return
}
const result = await createMutation.mutateAsync({
programId: state.programId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
if (publish && result.pipeline.id) {
await publishMutation.mutateAsync({ id: result.pipeline.id })
}
}
const isSaving = createMutation.isPending && !publishMutation.isPending
const isSubmitting = publishMutation.isPending
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Please select an edition first to create a pipeline.
</p>
</div>
</div>
</div>
)
}
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: 'Configure pipeline stages',
isValid: tracksValid,
},
{
title: 'Screening',
description: 'Gate rules and AI screening',
isValid: !!filterStage,
},
{
title: 'Evaluation',
description: 'Jury assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: 'Special award tracks',
isValid: true, // Awards are optional
},
{
title: 'Live Finals',
description: 'Voting and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications',
isValid: true, // Always valid
},
{
title: 'Review & Create',
description: 'Validation summary',
isValid: allValid,
},
]
return (
<div className="space-y-6 pb-8">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Configure the full pipeline structure for project evaluation
</p>
</div>
</div>
{/* Sidebar Stepper */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSave={() => handleSave(false)}
onSubmit={() => handleSave(true)}
isSaving={isSaving}
isSubmitting={isSubmitting}
saveLabel="Save Draft"
submitLabel="Save & Publish"
canSubmit={allValid}
>
{/* Step 0: Basics */}
<div>
<BasicsSection state={state} onChange={updateState} />
</div>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</div>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</div>
{/* Step 8: Review & Create */}
<div>
<ReviewSection state={state} />
</div>
</SidebarStepper>
</div>
)
}

View File

@@ -1,12 +0,0 @@
import { redirect } from 'next/navigation'
type AdvancedPipelinePageProps = {
params: Promise<{ id: string }>
}
export default async function AdvancedPipelinePage({
params,
}: AdvancedPipelinePageProps) {
const { id } = await params
redirect(`/admin/rounds/pipeline/${id}` as never)
}

View File

@@ -1,11 +0,0 @@
import { redirect } from 'next/navigation'
type EditPipelinePageProps = {
params: Promise<{ id: string }>
}
export default async function EditPipelinePage({ params }: EditPipelinePageProps) {
const { id } = await params
// Editing now happens inline on the detail page
redirect(`/admin/rounds/pipeline/${id}` as never)
}

View File

@@ -1,675 +0,0 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import type { Route } from 'next'
import {
ArrowLeft,
MoreHorizontal,
Rocket,
Archive,
Layers,
GitBranch,
Loader2,
ChevronDown,
Save,
Wand2,
} from 'lucide-react'
import { InlineEditableText } from '@/components/ui/inline-editable-text'
import { PipelineFlowchart } from '@/components/admin/pipeline/pipeline-flowchart'
import { StageDetailSheet } from '@/components/admin/pipeline/stage-detail-sheet'
import { usePipelineInlineEdit } from '@/hooks/use-pipeline-inline-edit'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor'
import { defaultNotificationConfig } from '@/lib/pipeline-defaults'
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-emerald-100 text-emerald-700',
ARCHIVED: 'bg-muted text-muted-foreground',
CLOSED: 'bg-blue-100 text-blue-700',
}
export default function PipelineDetailPage() {
const params = useParams()
const pipelineId = params.id as string
const utils = trpc.useUtils()
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [structureTracks, setStructureTracks] = useState<WizardTrackConfig[]>([])
const [notificationConfig, setNotificationConfig] = useState<Record<string, boolean>>({})
const [overridePolicy, setOverridePolicy] = useState<Record<string, unknown>>({
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
})
const [structureDirty, setStructureDirty] = useState(false)
const [settingsDirty, setSettingsDirty] = useState(false)
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
id: pipelineId,
})
const { isUpdating, updatePipeline, updateStageConfig } =
usePipelineInlineEdit(pipelineId)
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => toast.success('Pipeline published'),
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.pipeline.update.useMutation({
onSuccess: () => toast.success('Pipeline updated'),
onError: (err) => toast.error(err.message),
})
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
onSuccess: async () => {
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Pipeline structure updated')
setStructureDirty(false)
},
onError: (err) => toast.error(err.message),
})
const materializeRequirementsMutation =
trpc.file.materializeRequirementsFromConfig.useMutation({
onSuccess: async (result) => {
if (result.skipped && result.reason === 'already_materialized') {
toast.message('Requirements already materialized')
return
}
if (result.skipped && result.reason === 'no_config_requirements') {
toast.message('No legacy config requirements found')
return
}
await utils.file.listRequirements.invalidate()
toast.success(`Materialized ${result.created} requirement(s)`)
},
onError: (err) => toast.error(err.message),
})
// Auto-select first track on load
useEffect(() => {
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
const firstTrack = pipeline.tracks.sort((a, b) => a.sortOrder - b.sortOrder)[0]
setSelectedTrackId(firstTrack.id)
}
}, [pipeline, selectedTrackId])
useEffect(() => {
if (!pipeline) return
const nextTracks = pipeline.tracks
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) =>
toWizardTrackConfig({
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingMode: track.routingMode,
decisionMode: track.decisionMode,
stages: track.stages.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: stage.configJson,
})),
specialAward: track.specialAward
? {
name: track.specialAward.name,
description: track.specialAward.description,
scoringMode: track.specialAward.scoringMode,
}
: null,
})
)
setStructureTracks(nextTracks)
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
setNotificationConfig(
((settings.notificationConfig as Record<string, boolean> | undefined) ??
defaultNotificationConfig()) as Record<string, boolean>
)
setOverridePolicy(
((settings.overridePolicy as Record<string, unknown> | undefined) ?? {
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
}) as Record<string, unknown>
)
setStructureDirty(false)
setSettingsDirty(false)
}, [pipeline])
const trackOptionsForEditors = useMemo(
() =>
(pipeline?.tracks ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => ({
id: track.id,
name: track.name,
stages: track.stages
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((stage) => ({
id: stage.id,
name: stage.name,
sortOrder: stage.sortOrder,
})),
})),
[pipeline]
)
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!pipeline) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested pipeline does not exist
</p>
</div>
</div>
</div>
)
}
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
const selectedStage = selectedTrack?.stages.find(
(s) => s.id === selectedStageId
)
const mainTrackDraft = structureTracks.find((track) => track.kind === 'MAIN')
const hasAwardTracks = pipeline.tracks.some((t) => t.kind === 'AWARD')
const hasMultipleTracks = pipeline.tracks.length > 1
const handleTrackChange = (trackId: string) => {
setSelectedTrackId(trackId)
setSelectedStageId(null)
}
const handleStageSelect = (stageId: string) => {
setSelectedStageId(stageId)
setSheetOpen(true)
}
const handleStatusChange = async (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
await updateMutation.mutateAsync({
id: pipelineId,
status: newStatus,
})
}
const updateMainTrackStages = (stages: WizardTrackConfig['stages']) => {
setStructureTracks((prev) =>
prev.map((track) =>
track.kind === 'MAIN'
? {
...track,
stages,
}
: track
)
)
setStructureDirty(true)
}
const handleSaveStructure = async () => {
await updateStructureMutation.mutateAsync({
id: pipelineId,
tracks: structureTracks.map((track) => ({
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind,
sortOrder: track.sortOrder,
routingModeDefault: track.routingModeDefault,
decisionMode: track.decisionMode,
stages: track.stages.map((stage) => ({
id: stage.id,
name: stage.name,
slug: stage.slug,
stageType: stage.stageType,
sortOrder: stage.sortOrder,
configJson: stage.configJson,
})),
awardConfig: track.awardConfig,
})),
autoTransitions: false,
})
}
const handleSaveSettings = async () => {
const currentSettings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
await updatePipeline({
settingsJson: {
...currentSettings,
notificationConfig,
overridePolicy,
},
})
setSettingsDirty(false)
}
// Prepare flowchart data for the selected track
const flowchartTracks = selectedTrack ? [selectedTrack] : []
return (
<div className="space-y-8">
{/* Header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 min-w-0">
<Link href="/admin/rounds/pipelines" className="mt-1">
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<InlineEditableText
value={pipeline.name}
onSave={(newName) => updatePipeline({ name: newName })}
variant="h1"
placeholder="Untitled Pipeline"
disabled={isUpdating}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
statusColors[pipeline.status] ?? '',
'hover:opacity-80'
)}
>
{pipeline.status}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => handleStatusChange('DRAFT')}
disabled={pipeline.status === 'DRAFT' || updateMutation.isPending}
>
Draft
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange('ACTIVE')}
disabled={pipeline.status === 'ACTIVE' || updateMutation.isPending}
>
Active
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange('CLOSED')}
disabled={pipeline.status === 'CLOSED' || updateMutation.isPending}
>
Closed
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={pipeline.status === 'ARCHIVED' || updateMutation.isPending}
>
Archived
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">slug:</span>
<InlineEditableText
value={pipeline.slug}
onSave={(newSlug) => updatePipeline({ slug: newSlug })}
variant="mono"
placeholder="pipeline-slug"
disabled={isUpdating}
/>
</div>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/pipeline/${pipelineId}/wizard` as Route}>
<Wand2 className="h-4 w-4 mr-2" />
Edit in Wizard
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{pipeline.status === 'DRAFT' && (
<DropdownMenuItem
disabled={publishMutation.isPending}
onClick={() => publishMutation.mutate({ id: pipelineId })}
>
{publishMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Rocket className="h-4 w-4 mr-2" />
)}
Publish
</DropdownMenuItem>
)}
{pipeline.status === 'ACTIVE' && (
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() => handleStatusChange('CLOSED')}
>
Close Pipeline
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateMutation.isPending}
onClick={() => handleStatusChange('ARCHIVED')}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Pipeline Summary */}
<div className="grid gap-3 grid-cols-3">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Tracks</span>
</div>
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
<p className="text-xs text-muted-foreground">
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Stages</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
</p>
<p className="text-xs text-muted-foreground">across all tracks</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Transitions</span>
</div>
<p className="text-2xl font-bold mt-1">
{pipeline.tracks.reduce(
(sum, t) =>
sum +
t.stages.reduce(
(s, stage) => s + stage.transitionsFrom.length,
0
),
0
)}
</p>
<p className="text-xs text-muted-foreground">stage connections</p>
</CardContent>
</Card>
</div>
{/* Track Switcher (only if multiple tracks) */}
{hasMultipleTracks && (
<div className="flex items-center gap-2 flex-wrap overflow-x-auto pb-1">
{pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((track) => (
<button
key={track.id}
onClick={() => handleTrackChange(track.id)}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
selectedTrackId === track.id
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
)}
>
<span>{track.name}</span>
<Badge
variant="outline"
className={cn(
'text-[9px] h-4 px-1',
selectedTrackId === track.id
? 'border-primary-foreground/20 text-primary-foreground/80'
: ''
)}
>
{track.kind}
</Badge>
</button>
))}
</div>
)}
{/* Pipeline Flowchart */}
{flowchartTracks.length > 0 ? (
<div>
<PipelineFlowchart
tracks={flowchartTracks}
selectedStageId={selectedStageId}
onStageSelect={handleStageSelect}
/>
<p className="text-xs text-muted-foreground mt-2">
Click a stage to edit its configuration
</p>
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No tracks configured for this pipeline
</CardContent>
</Card>
)}
{/* Stage Detail Sheet */}
<StageDetailSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
stage={
selectedStage
? {
id: selectedStage.id,
name: selectedStage.name,
stageType: selectedStage.stageType,
configJson: selectedStage.configJson as Record<string, unknown> | null,
}
: null
}
onSaveConfig={updateStageConfig}
isSaving={isUpdating}
pipelineId={pipelineId}
materializeRequirements={(stageId) =>
materializeRequirementsMutation.mutate({ stageId })
}
isMaterializing={materializeRequirementsMutation.isPending}
/>
{/* Stage Management */}
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Stage Management</h2>
<p className="text-sm text-muted-foreground mb-4">
Add, remove, reorder, or change stage types. Click a stage in the flowchart to edit its settings.
</p>
<Card>
<CardContent className="pt-4 space-y-6">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">Pipeline Structure</h3>
<Button
type="button"
size="sm"
onClick={handleSaveStructure}
disabled={!structureDirty || updateStructureMutation.isPending}
>
{updateStructureMutation.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Structure
</Button>
</div>
{mainTrackDraft ? (
<MainTrackSection
stages={mainTrackDraft.stages}
onChange={updateMainTrackStages}
/>
) : (
<p className="text-sm text-muted-foreground">
No main track configured.
</p>
)}
<AwardsSection
tracks={structureTracks}
onChange={(tracks) => {
setStructureTracks(tracks)
setStructureDirty(true)
}}
/>
</CardContent>
</Card>
</div>
{/* Award Governance (only if award tracks exist) */}
{hasAwardTracks && (
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Award Governance</h2>
<p className="text-sm text-muted-foreground mb-4">
Configure special awards, voting, and scoring for award tracks.
</p>
<AwardGovernanceEditor
pipelineId={pipelineId}
tracks={pipeline.tracks
.filter((track) => track.kind === 'AWARD')
.map((track) => ({
id: track.id,
name: track.name,
decisionMode: track.decisionMode,
specialAward: track.specialAward
? {
id: track.specialAward.id,
name: track.specialAward.name,
description: track.specialAward.description,
criteriaText: track.specialAward.criteriaText,
useAiEligibility: track.specialAward.useAiEligibility,
scoringMode: track.specialAward.scoringMode,
maxRankedPicks: track.specialAward.maxRankedPicks,
votingStartAt: track.specialAward.votingStartAt,
votingEndAt: track.specialAward.votingEndAt,
status: track.specialAward.status,
}
: null,
}))}
/>
</div>
)}
{/* Settings */}
<div>
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Settings</h2>
<Card>
<CardContent className="pt-4 space-y-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">Notifications and Overrides</h3>
<Button
type="button"
size="sm"
onClick={handleSaveSettings}
disabled={!settingsDirty || isUpdating}
>
{isUpdating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Settings
</Button>
</div>
<NotificationsSection
config={notificationConfig}
onChange={(next) => {
setNotificationConfig(next)
setSettingsDirty(true)
}}
overridePolicy={overridePolicy}
onOverridePolicyChange={(next) => {
setOverridePolicy(next)
setSettingsDirty(true)
}}
/>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,410 +0,0 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
import type { StepConfig } from '@/components/ui/sidebar-stepper'
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import { defaultNotificationConfig, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { toWizardTrackConfig } from '@/lib/pipeline-conversions'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
export default function EditPipelineWizardPage() {
const router = useRouter()
const params = useParams()
const pipelineId = params.id as string
const [state, setState] = useState<WizardState | null>(null)
const [currentStep, setCurrentStep] = useState(0)
const initialStateRef = useRef<string>('')
// Load existing pipeline data
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery(
{ id: pipelineId },
{ enabled: !!pipelineId }
)
// Initialize state when pipeline data loads
useEffect(() => {
if (pipeline && !state) {
const settings = (pipeline.settingsJson as Record<string, unknown> | null) ?? {}
const initialState: WizardState = {
name: pipeline.name,
slug: pipeline.slug,
programId: pipeline.programId,
settingsJson: settings,
tracks: pipeline.tracks
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(track => toWizardTrackConfig({
id: track.id,
name: track.name,
slug: track.slug,
kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE',
sortOrder: track.sortOrder,
routingMode: track.routingMode as 'SHARED' | 'EXCLUSIVE' | null,
decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null,
stages: track.stages.map(s => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS',
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
specialAward: track.specialAward ? {
name: track.specialAward.name,
description: track.specialAward.description,
scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED',
} : null,
})),
notificationConfig: (settings.notificationConfig as Record<string, boolean>) ?? defaultNotificationConfig(),
overridePolicy: (settings.overridePolicy as Record<string, unknown>) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
}
setState(initialState)
initialStateRef.current = JSON.stringify(initialState)
}
}, [pipeline, state])
// Dirty tracking — warn on navigate away
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (state && JSON.stringify(state) !== initialStateRef.current) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [state])
const updateState = useCallback((updates: Partial<WizardState>) => {
setState((prev) => prev ? { ...prev, ...updates } : prev)
}, [])
// Mutations
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
onError: (err) => {
toast.error(err.message)
},
})
const updateSettingsMutation = trpc.pipeline.update.useMutation({
onError: (err) => {
toast.error(err.message)
},
})
const publishMutation = trpc.pipeline.publish.useMutation({
onSuccess: () => {
toast.success('Pipeline published successfully')
},
onError: (err) => {
toast.error(err.message)
},
})
const handleSave = async (publish: boolean) => {
if (!state) return
const validation = validateAll(state)
if (!validation.valid) {
toast.error('Please fix validation errors before saving')
if (!validation.sections.basics.valid) setCurrentStep(0)
else if (!validation.sections.tracks.valid) setCurrentStep(2)
return
}
await updateStructureMutation.mutateAsync({
id: pipelineId,
name: state.name,
slug: state.slug,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
tracks: state.tracks.map((t) => ({
id: t.id,
name: t.name,
slug: t.slug,
kind: t.kind,
sortOrder: t.sortOrder,
routingModeDefault: t.routingModeDefault,
decisionMode: t.decisionMode,
stages: t.stages.map((s) => ({
id: s.id,
name: s.name,
slug: s.slug,
stageType: s.stageType,
sortOrder: s.sortOrder,
configJson: s.configJson,
})),
awardConfig: t.awardConfig,
})),
autoTransitions: true,
})
await updateSettingsMutation.mutateAsync({
id: pipelineId,
settingsJson: {
...state.settingsJson,
notificationConfig: state.notificationConfig,
overridePolicy: state.overridePolicy,
},
})
if (publish) {
await publishMutation.mutateAsync({ id: pipelineId })
}
initialStateRef.current = JSON.stringify(state)
toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved')
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
}
const isSaving = updateStructureMutation.isPending && !publishMutation.isPending
const isSubmitting = publishMutation.isPending
// Loading state
if (isLoading || !state) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
<p className="text-sm text-muted-foreground">Loading pipeline data...</p>
</div>
</div>
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
)
}
// Get stage configs from the main track
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
const updateStageConfig = (stageType: string, configJson: Record<string, unknown>) => {
setState((prev) => {
if (!prev) return prev
return {
...prev,
tracks: prev.tracks.map((track) => {
if (track.kind !== 'MAIN') return track
return {
...track,
stages: track.stages.map((stage) =>
stage.stageType === stageType ? { ...stage, configJson } : stage
),
}
}),
}
})
}
const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => {
setState((prev) => {
if (!prev) return prev
return {
...prev,
tracks: prev.tracks.map((track) =>
track.kind === 'MAIN' ? { ...track, stages } : track
),
}
})
}
// Validation
const basicsValid = validateBasics(state).valid
const tracksValid = validateTracks(state.tracks).valid
const allValid = validateAll(state).valid
// Step configuration
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Pipeline name and program',
isValid: basicsValid,
},
{
title: 'Intake',
description: 'Submission window & files',
isValid: !!intakeStage,
},
{
title: 'Main Track Stages',
description: 'Configure pipeline stages',
isValid: tracksValid,
},
{
title: 'Screening',
description: 'Gate rules and AI screening',
isValid: !!filterStage,
},
{
title: 'Evaluation',
description: 'Jury assignment strategy',
isValid: !!evalStage,
},
{
title: 'Awards',
description: 'Special award tracks',
isValid: true,
},
{
title: 'Live Finals',
description: 'Voting and reveal settings',
isValid: !!liveStage,
},
{
title: 'Notifications',
description: 'Event notifications',
isValid: true,
},
{
title: 'Review & Save',
description: 'Validation summary',
isValid: allValid,
},
]
return (
<div className="space-y-6 pb-8">
{/* Header */}
<div className="flex items-center gap-3">
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Edit Pipeline (Wizard)</h1>
<p className="text-sm text-muted-foreground">
Modify the pipeline structure for project evaluation
</p>
</div>
</div>
{/* Sidebar Stepper */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSave={() => handleSave(false)}
onSubmit={() => handleSave(true)}
isSaving={isSaving}
isSubmitting={isSubmitting}
saveLabel="Save Changes"
submitLabel="Save & Publish"
canSubmit={allValid}
>
{/* Step 0: Basics */}
<div>
<BasicsSection state={state} onChange={updateState} />
</div>
{/* Step 1: Intake */}
<div>
<IntakeSection
config={intakeConfig}
onChange={(c) =>
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 2: Main Track Stages */}
<div>
<MainTrackSection
stages={mainTrack?.stages ?? []}
onChange={updateMainTrackStages}
/>
</div>
{/* Step 3: Screening */}
<div>
<FilteringSection
config={filterConfig}
onChange={(c) =>
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 4: Evaluation */}
<div>
<AssignmentSection
config={evalConfig}
onChange={(c) =>
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 5: Awards */}
<div>
<AwardsSection
tracks={state.tracks}
onChange={(tracks) => updateState({ tracks })}
/>
</div>
{/* Step 6: Live Finals */}
<div>
<LiveFinalsSection
config={liveConfig}
onChange={(c) =>
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
}
/>
</div>
{/* Step 7: Notifications */}
<div>
<NotificationsSection
config={state.notificationConfig}
onChange={(notificationConfig) => updateState({ notificationConfig })}
overridePolicy={state.overridePolicy}
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
/>
</div>
{/* Step 8: Review & Save */}
<div>
<ReviewSection state={state} />
</div>
</SidebarStepper>
</div>
)
}

View File

@@ -0,0 +1,180 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
export default function ApplicantSubmissionWindowPage() {
const params = useParams()
const router = useRouter()
const windowId = params.windowId as string
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
const { data: window, isLoading } = trpc.round.getById.useQuery(
{ id: windowId },
{ enabled: !!windowId }
)
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
{ windowId },
{
enabled: !!windowId,
refetchInterval: 60000, // Refresh every minute
}
)
const handleUpload = (requirementId: string, file: File) => {
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
toast.success(`File "${file.name}" selected for upload`)
// In a real implementation, this would trigger file upload
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96" />
</div>
)
}
if (!window) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Submission window not found</p>
</CardContent>
</Card>
</div>
)
}
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
const deadline = window.windowCloseAt
? new Date(window.windowCloseAt)
: null
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
<p className="text-muted-foreground mt-1">
Upload required documents for this submission window
</p>
</div>
</div>
{/* Deadline card */}
{deadline && (
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
<CardContent className="flex items-start gap-3 p-4">
{isLocked ? (
<>
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
<p className="text-sm text-red-700 mt-1">
This submission window closed on {deadline.toLocaleDateString()}. No further
uploads are allowed.
</p>
</div>
</>
) : (
<>
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Deadline Countdown</p>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold tabular-nums text-amber-600">
{daysRemaining !== null ? daysRemaining : '—'}
</span>
<span className="text-sm text-muted-foreground">
day{daysRemaining !== 1 ? 's' : ''} remaining
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Due: {deadline.toLocaleString()}
</p>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* File requirements */}
<Card>
<CardHeader>
<CardTitle>File Requirements</CardTitle>
<CardDescription>
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* File requirements would be fetched separately in a real implementation */}
{false ? (
[].map((req: any) => (
<FileUploadSlot
key={req.id}
requirement={{
id: req.id,
label: req.label,
description: req.description,
mimeTypes: req.mimeTypes || [],
maxSizeMb: req.maxSizeMb,
required: req.required || false,
}}
isLocked={isLocked}
onUpload={(file) => handleUpload(req.id, file)}
/>
))
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
No file requirements defined for this window
</p>
</div>
)}
</CardContent>
</Card>
{!isLocked && (
<div className="flex justify-end gap-3">
<Button variant="outline">Save Draft</Button>
<Button className="bg-brand-blue hover:bg-brand-blue-light">
Submit All Files
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
import { toast } from 'sonner'
export default function ApplicantCompetitionsPage() {
const { data: session } = useSession()
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: !!session,
})
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96" />
</div>
)
}
const competitionId = myProject?.project?.programId
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Competition Timeline</h1>
<p className="text-muted-foreground mt-1">
Track your progress through competition rounds
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
{!competitionId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Active Competition</h2>
<p className="text-muted-foreground text-center max-w-md">
You don&apos;t have an active project in any competition yet. Submit your application
when a competition opens.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ApplicantCompetitionTimeline competitionId={competitionId} />
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={'/applicant/documents' as Route}>
<FileText className="mr-2 h-4 w-4" />
View Documents
</Link>
</Button>
{myProject?.openRounds && myProject.openRounds.length > 0 && (
<p className="text-sm text-muted-foreground px-3 py-2 bg-muted/50 rounded-md">
{myProject.openRounds.length} submission window
{myProject.openRounds.length !== 1 ? 's' : ''} currently open
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Timeline Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Current Status:</span>
<span className="font-medium">
{myProject?.currentStatus || 'Unknown'}
</span>
</div>
{myProject?.project && (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">Project:</span>
<span className="font-medium truncate ml-2" title={myProject.project.title}>
{myProject.project.title}
</span>
</div>
{myProject.project.submittedAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">Submitted:</span>
<span className="font-medium">
{new Date(myProject.project.submittedAt).toLocaleDateString()}
</span>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
</div>
)}
</div>
)
}

View File

@@ -82,7 +82,7 @@ export default function ApplicantDocumentsPage() {
)
}
const { project, openStages } = data
const { project, openRounds } = data
const isDraft = !project.submittedAt
return (
@@ -98,23 +98,23 @@ export default function ApplicantDocumentsPage() {
</p>
</div>
{/* Per-stage upload sections */}
{openStages.length > 0 && (
{/* Per-round upload sections */}
{openRounds.length > 0 && (
<div className="space-y-6">
{openStages.map((stage) => {
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
const now = new Date()
const hasDeadline = !!stage.windowCloseAt
const deadlinePassed = hasDeadline && now > new Date(stage.windowCloseAt!)
const hasDeadline = !!round.windowCloseAt
const deadlinePassed = hasDeadline && now > new Date(round.windowCloseAt!)
const isLate = deadlinePassed
return (
<Card key={stage.id}>
<Card key={round.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{stage.name}</CardTitle>
<CardTitle className="text-lg">{round.name}</CardTitle>
<CardDescription>
Upload documents for this stage
Upload documents for this round
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -127,7 +127,7 @@ export default function ApplicantDocumentsPage() {
{hasDeadline && !deadlinePassed && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Due {new Date(stage.windowCloseAt!).toLocaleDateString()}
Due {new Date(round.windowCloseAt!).toLocaleDateString()}
</Badge>
)}
</div>
@@ -136,7 +136,7 @@ export default function ApplicantDocumentsPage() {
<CardContent>
<RequirementUploadList
projectId={project.id}
stageId={stage.id}
roundId={round.id}
disabled={false}
/>
</CardContent>
@@ -163,7 +163,7 @@ export default function ApplicantDocumentsPage() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div
@@ -197,13 +197,13 @@ export default function ApplicantDocumentsPage() {
</CardContent>
</Card>
{/* No open stages message */}
{openStages.length === 0 && project.files.length === 0 && (
{/* No open rounds message */}
{openRounds.length === 0 && project.files.length === 0 && (
<Card className="bg-muted/50">
<CardContent className="p-6 text-center">
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground">
No stages are currently open for document submissions.
No rounds are currently open for document submissions.
</p>
</CardContent>
</Card>

View File

@@ -98,7 +98,7 @@ export default function ApplicantDashboardPage() {
)
}
const { project, timeline, currentStatus, openStages } = data
const { project, timeline, currentStatus, openRounds } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
@@ -221,7 +221,7 @@ export default function ApplicantDashboardPage() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openStages.length > 0 ? `${openStages.length} stage(s) open` : 'View uploads'}
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
@@ -347,10 +347,10 @@ export default function ApplicantDashboardPage() {
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
{openStages.length > 0 && openStages[0].windowCloseAt && (
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{new Date(openStages[0].windowCloseAt).toLocaleDateString()}</span>
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
</div>
)}
</CardContent>

View File

@@ -1,167 +0,0 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
AlertCircle,
Clock,
FileText,
Upload,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
export default function StageDocumentsPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const { data: requirements, isLoading: reqLoading } =
trpc.stage.getRequirements.useQuery(
{ stageId, projectId },
{ enabled: !!projectId }
)
const isWindowOpen = requirements?.windowStatus?.isOpen ?? false
const isLate = requirements?.windowStatus?.isLate ?? false
const closeAt = requirements?.windowStatus?.closesAt
? new Date(requirements.windowStatus.closesAt)
: null
if (reqLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/applicant/pipeline" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Documents</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Upload required documents for this stage
</p>
</div>
<StageWindowBadge
windowOpenAt={requirements?.deadlineInfo?.windowOpenAt}
windowCloseAt={requirements?.deadlineInfo?.windowCloseAt}
/>
</div>
{/* Deadline info */}
{closeAt && isWindowOpen && (
<Card>
<CardContent className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Submission deadline</span>
</div>
<CountdownTimer deadline={closeAt} label="Closes in" />
</CardContent>
</Card>
)}
{/* Late submission warning */}
{isLate && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
The submission window has passed. Late submissions may be accepted at the discretion of the administrators.
</p>
</CardContent>
</Card>
)}
{/* Window closed */}
{!isWindowOpen && !isLate && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
The document submission window for this stage is closed.
</p>
</CardContent>
</Card>
)}
{/* File requirements */}
{requirements?.fileRequirements && requirements.fileRequirements.length > 0 ? (
<RequirementUploadList
projectId={projectId}
stageId={stageId}
disabled={!isWindowOpen && !isLate}
/>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No document requirements</p>
<p className="text-sm text-muted-foreground mt-1">
There are no specific document requirements for this stage.
</p>
</CardContent>
</Card>
)}
{/* Uploaded files summary */}
{requirements?.uploadedFiles && requirements.uploadedFiles.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{requirements.uploadedFiles.map((file: { id: string; fileName: string; size: number; createdAt: string | Date }) => (
<div key={file.id} className="flex items-center gap-3 text-sm">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{file.fileName}</span>
<span className="text-xs text-muted-foreground shrink-0">
{(file.size / (1024 * 1024)).toFixed(1)}MB
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,278 +0,0 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
CheckCircle2,
XCircle,
Clock,
ArrowRight,
AlertCircle,
} from 'lucide-react'
const stateLabels: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Not Selected',
COMPLETED: 'Completed',
WAITING: 'Waiting',
}
const stateColors: Record<string, string> = {
PASSED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
COMPLETED: 'text-emerald-600 bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:bg-emerald-950/30 dark:border-emerald-900',
REJECTED: 'text-destructive bg-destructive/5 border-destructive/30',
IN_PROGRESS: 'text-blue-600 bg-blue-50 border-blue-200 dark:text-blue-400 dark:bg-blue-950/30 dark:border-blue-900',
PENDING: 'text-muted-foreground bg-muted border-muted',
WAITING: 'text-amber-600 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/30 dark:border-amber-900',
}
export default function StageStatusPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const programId = project?.program?.id ?? ''
const { data: pipelineView } =
trpc.pipeline.getApplicantView.useQuery(
{ programId, projectId },
{ enabled: !!programId && !!projectId }
)
const { data: timeline, isLoading } =
trpc.stage.getApplicantTimeline.useQuery(
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
{ enabled: !!projectId && !!pipelineView?.pipelineId }
)
// Find the specific stage
const stageData = timeline?.find((item) => item.stageId === stageId)
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/applicant/pipeline" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<span className="text-sm text-muted-foreground">Back to Pipeline</span>
</div>
{stageData ? (
<>
{/* Stage state card */}
<Card className={`border ${stateColors[stageData.state] ?? ''}`}>
<CardContent className="py-8 text-center">
<div className="flex flex-col items-center gap-3">
{stageData.state === 'PASSED' || stageData.state === 'COMPLETED' ? (
<CheckCircle2 className="h-12 w-12 text-emerald-600" />
) : stageData.state === 'REJECTED' ? (
<XCircle className="h-12 w-12 text-destructive" />
) : (
<Clock className="h-12 w-12 text-blue-600" />
)}
<div>
<h2 className="text-xl font-bold">{stageData.stageName}</h2>
<Badge className="mt-2 text-sm">
{stateLabels[stageData.state] ?? stageData.state}
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Decision details */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Stage Type</p>
<p className="text-sm font-medium capitalize">
{stageData.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
</div>
{stageData.enteredAt && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Entered</p>
<p className="text-sm font-medium">
{new Date(stageData.enteredAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
)}
{stageData.exitedAt && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Exited</p>
<p className="text-sm font-medium">
{new Date(stageData.exitedAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Next steps */}
{(stageData.state === 'IN_PROGRESS' || stageData.state === 'PENDING') && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ArrowRight className="h-4 w-4" />
Next Steps
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{stageData.stageType === 'INTAKE' && (
<>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Make sure all required documents are uploaded before the deadline.
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
You will be notified once reviewers complete their evaluation.
</li>
</>
)}
{stageData.stageType === 'EVALUATION' && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Your project is being reviewed by jury members. Results will be shared once evaluation is complete.
</li>
)}
{stageData.stageType === 'LIVE_FINAL' && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Prepare for the live presentation. Check your email for schedule and logistics details.
</li>
)}
{!['INTAKE', 'EVALUATION', 'LIVE_FINAL'].includes(stageData.stageType) && (
<li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-brand-teal shrink-0 mt-0.5" />
Your project is progressing through this stage. Updates will appear here.
</li>
)}
</ul>
</CardContent>
</Card>
)}
{/* Full timeline */}
{timeline && timeline.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Full Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-0">
{timeline.map((item, index) => (
<div key={item.stageId} className="relative flex gap-4">
{index < timeline.length - 1 && (
<div className={`absolute left-[11px] top-[24px] h-full w-0.5 ${
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: 'bg-muted'
}`} />
)}
<div className="relative z-10 flex h-6 w-6 shrink-0 items-center justify-center">
<div className={`h-3 w-3 rounded-full ${
item.stageId === stageId
? 'ring-2 ring-brand-blue ring-offset-2 bg-brand-blue'
: item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: item.state === 'REJECTED'
? 'bg-destructive'
: item.isCurrent
? 'bg-blue-500'
: 'bg-muted-foreground/30'
}`} />
</div>
<div className="flex-1 pb-6">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium ${
item.stageId === stageId ? 'text-brand-blue dark:text-brand-teal' : ''
}`}>
{item.stageName}
</p>
<Badge
variant={
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'success'
: item.state === 'REJECTED'
? 'destructive'
: 'secondary'
}
className="text-xs"
>
{stateLabels[item.state] ?? item.state}
</Badge>
</div>
{item.enteredAt && (
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(item.enteredAt).toLocaleDateString()}
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Stage not found</p>
<p className="text-sm text-muted-foreground mt-1">
Your project has not entered this stage yet.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,267 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Upload,
Users,
MessageSquare,
ArrowRight,
FileText,
Clock,
CheckCircle2,
XCircle,
Layers,
} from 'lucide-react'
import { StageTimeline } from '@/components/shared/stage-timeline'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
const stateLabels: Record<string, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Not Selected',
COMPLETED: 'Completed',
WAITING: 'Waiting',
}
const stateVariants: Record<string, 'success' | 'destructive' | 'warning' | 'secondary' | 'info'> = {
PENDING: 'secondary',
IN_PROGRESS: 'info',
PASSED: 'success',
REJECTED: 'destructive',
COMPLETED: 'success',
WAITING: 'warning',
}
export default function ApplicantPipelinePage() {
// Get applicant's project via dashboard endpoint
const { data: dashboard } = trpc.applicant.getMyDashboard.useQuery()
const project = dashboard?.project
const projectId = project?.id ?? ''
const programId = project?.program?.id ?? ''
const { data: pipelineView, isLoading: pipelineLoading } =
trpc.pipeline.getApplicantView.useQuery(
{ programId, projectId },
{ enabled: !!programId && !!projectId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.stage.getApplicantTimeline.useQuery(
{ projectId, pipelineId: pipelineView?.pipelineId ?? '' },
{ enabled: !!projectId && !!pipelineView?.pipelineId }
)
const isLoading = pipelineLoading || timelineLoading
if (!project && !isLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No project found</p>
<p className="text-sm text-muted-foreground mt-1">
You don&apos;t have a project in the current edition yet.
</p>
</CardContent>
</Card>
</div>
)
}
if (isLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-48 w-full" />
</div>
)
}
// Build timeline items for StageTimeline
const timelineItems = timeline?.map((item) => ({
id: item.stageId,
name: item.stageName,
stageType: item.stageType,
isCurrent: item.isCurrent,
state: item.state,
enteredAt: item.enteredAt,
})) ?? []
// Find current stage
const currentStage = timeline?.find((item) => item.isCurrent)
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Pipeline Progress</h1>
<p className="text-muted-foreground mt-0.5">Track your project through the selection pipeline</p>
</div>
{/* Project title + status */}
<Card>
<CardContent className="py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{project?.title}</h2>
<p className="text-sm text-muted-foreground">{(project as { teamName?: string } | undefined)?.teamName}</p>
</div>
{currentStage && (
<Badge variant={stateVariants[currentStage.state] ?? 'secondary'}>
{stateLabels[currentStage.state] ?? currentStage.state}
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Stage Timeline visualization */}
{timelineItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Pipeline Progress</CardTitle>
</CardHeader>
<CardContent>
<StageTimeline stages={timelineItems} orientation="horizontal" />
</CardContent>
</Card>
)}
{/* Current stage details */}
{currentStage && (
<Card className="border-brand-blue/30 dark:border-brand-teal/30">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Current Stage</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<h3 className="font-semibold">{currentStage.stageName}</h3>
<p className="text-sm text-muted-foreground capitalize">
{currentStage.stageType.toLowerCase().replace(/_/g, ' ')}
</p>
</div>
{currentStage.enteredAt && (
<p className="text-xs text-muted-foreground">
Entered {new Date(currentStage.enteredAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
)}
</CardContent>
</Card>
)}
{/* Decision history */}
{timeline && timeline.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Stage History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{timeline.map((item) => (
<div
key={item.stageId}
className="flex items-center justify-between py-2 border-b last:border-0"
>
<div className="flex items-center gap-3">
<div className={cn(
'h-2 w-2 rounded-full',
item.state === 'PASSED' || item.state === 'COMPLETED'
? 'bg-emerald-500'
: item.state === 'REJECTED'
? 'bg-destructive'
: item.isCurrent
? 'bg-blue-500'
: 'bg-muted-foreground'
)} />
<div>
<p className="text-sm font-medium">{item.stageName}</p>
{item.enteredAt && (
<p className="text-xs text-muted-foreground">
{new Date(item.enteredAt).toLocaleDateString()}
</p>
)}
</div>
</div>
<Badge variant={stateVariants[item.state] ?? 'secondary'} className="text-xs">
{stateLabels[item.state] ?? item.state}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="grid gap-3 sm:grid-cols-3">
{currentStage && (
<Link
href={`/applicant/pipeline/${currentStage.stageId}/documents` as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-blue-50 p-2 dark:bg-blue-950/40">
<Upload className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="font-semibold text-sm">Upload Documents</p>
<p className="text-xs text-muted-foreground">Submit required files</p>
</div>
</Link>
)}
<Link
href={"/applicant/team" as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 dark:bg-teal-950/40">
<Users className="h-4 w-4 text-brand-teal" />
</div>
<div>
<p className="font-semibold text-sm">View Team</p>
<p className="text-xs text-muted-foreground">Team members</p>
</div>
</Link>
<Link
href={"/applicant/mentor" as Route}
className="group flex items-center gap-3 rounded-xl border p-4 transition-all hover:border-amber-500/30 hover:bg-amber-50/50 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-amber-50 p-2 dark:bg-amber-950/40">
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="font-semibold text-sm">Contact Mentor</p>
<p className="text-xs text-muted-foreground">Send a message</p>
</div>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function ApplicantError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Applicant section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('applicant')
}
}, [error])
const isChunk = isChunkLoadError(error)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
{isChunk
? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to your dashboard.'}
</p>
{!isChunk && (error.message || error.digest) && (
<p className="text-xs text-muted-foreground bg-muted rounded px-3 py-2 font-mono break-all">
{error.message || `Error ID: ${error.digest}`}
</p>
)}
<div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/applicant">
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</>
)}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select'
@@ -40,10 +41,21 @@ import {
Camera,
Globe,
FileText,
Scale,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'jury' | 'preferences' | 'complete'
type JuryPref = {
juryGroupMemberId: string
juryGroupName: string
currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null
selfServiceRatio: number | null
}
export default function OnboardingPage() {
const router = useRouter()
@@ -62,6 +74,7 @@ export default function OnboardingPage() {
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
const [juryPrefs, setJuryPrefs] = useState<Map<string, { cap?: number; ratio?: number }>>(new Map())
// Fetch current user data only after session is hydrated
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
@@ -105,6 +118,14 @@ export default function OnboardingPage() {
}
}, [userData, initialized])
// Fetch jury onboarding context
const { data: onboardingCtx } = trpc.user.getOnboardingContext.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const juryMemberships: JuryPref[] = onboardingCtx?.memberships ?? []
const hasJuryStep = onboardingCtx?.hasSelfServiceOptions ?? false
// Fetch feature flags only after session is hydrated
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
undefined,
@@ -117,14 +138,15 @@ export default function OnboardingPage() {
onSuccess: () => utils.user.me.invalidate(),
})
// Dynamic steps based on WhatsApp availability
// Dynamic steps based on WhatsApp availability and jury self-service
const steps: Step[] = useMemo(() => {
if (whatsappEnabled) {
return ['name', 'photo', 'country', 'bio', 'phone', 'tags', 'preferences', 'complete']
}
// Skip phone step if WhatsApp is disabled
return ['name', 'photo', 'country', 'bio', 'tags', 'preferences', 'complete']
}, [whatsappEnabled])
const base: Step[] = ['name', 'photo', 'country', 'bio']
if (whatsappEnabled) base.push('phone')
base.push('tags')
if (hasJuryStep) base.push('jury')
base.push('preferences', 'complete')
return base
}, [whatsappEnabled, hasJuryStep])
const currentIndex = steps.indexOf(step)
const totalVisibleSteps = steps.length - 1 // Exclude 'complete' from count
@@ -149,6 +171,23 @@ export default function OnboardingPage() {
const handleComplete = async () => {
try {
// Build jury preferences from state
const juryPreferences = juryMemberships
.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId)
if (!pref) return null
return {
juryGroupMemberId: m.juryGroupMemberId,
selfServiceCap: pref.cap,
selfServiceRatio: pref.ratio,
}
})
.filter(Boolean) as Array<{
juryGroupMemberId: string
selfServiceCap?: number
selfServiceRatio?: number
}>
await completeOnboarding.mutateAsync({
name,
country: country || undefined,
@@ -156,6 +195,7 @@ export default function OnboardingPage() {
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
juryPreferences: juryPreferences.length > 0 ? juryPreferences : undefined,
})
setStep('complete')
toast.success('Welcome to MOPC!')
@@ -227,6 +267,7 @@ export default function OnboardingPage() {
bio: 'About',
phone: 'Phone',
tags: 'Expertise',
jury: 'Jury',
preferences: 'Settings',
}
return (
@@ -473,7 +514,95 @@ export default function OnboardingPage() {
</>
)}
{/* Step 7: Preferences */}
{/* Jury Preferences Step (conditional) */}
{step === 'jury' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Scale className="h-5 w-5 text-primary" />
Jury Preferences
</CardTitle>
<CardDescription>
Customize your assignment preferences for each jury panel you belong to.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
</Label>
<Slider
value={[capValue]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, cap: v })
return next
})
}
min={1}
max={m.currentCap}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin default: {m.currentCap}. You may reduce this to match your availability.
</p>
</div>
)}
{m.allowRatioAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
</Label>
<Slider
value={[ratioValue * 100]}
onValueChange={([v]) =>
setJuryPrefs((prev) => {
const next = new Map(prev)
next.set(m.juryGroupMemberId, { ...pref, ratio: v / 100 })
return next
})
}
min={0}
max={100}
step={5}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
</div>
)}
</div>
)
})}
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Notification Preferences */}
{step === 'preferences' && (
<>
<CardHeader>

View File

@@ -0,0 +1,153 @@
'use client';
import { use, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { LiveVotingForm } from '@/components/jury/live-voting-form';
import { toast } from 'sonner';
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [notes, setNotes] = useState('');
const [priorDataOpen, setPriorDataOpen] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
// Placeholder for prior data - this would need to be implemented in evaluation router
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null;
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote submitted successfully');
},
onError: (err: any) => {
toast.error(err.message);
}
});
const handleVoteSubmit = (vote: { score: number }) => {
if (!cursor?.activeProject?.id) return;
submitVoteMutation.mutate({
sessionId: params.roundId,
projectId: cursor.activeProject.id,
score: vote.score
});
};
if (!cursor?.activeProject) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Waiting for ceremony to begin...</p>
<p className="mt-2 text-sm text-muted-foreground">
The admin will control which project is displayed
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Current Project Display */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl">{cursor.activeProject.title}</CardTitle>
<CardDescription className="mt-2">
Live project presentation
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{cursor.activeProject.description && (
<p className="text-muted-foreground">{cursor.activeProject.description}</p>
)}
</CardContent>
</Card>
{/* Prior Jury Data (Collapsible) */}
{priorData && (
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
{priorDataOpen ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
<p className="mt-1 text-2xl font-bold">
{priorData.averageScore?.toFixed(1) || 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
</div>
</div>
{priorData.strengths && (
<div>
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
<p className="mt-1 text-sm">{priorData.strengths}</p>
</div>
)}
{priorData.weaknesses && (
<div>
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)}
{/* Notes Section */}
<Card>
<CardHeader>
<CardTitle>Your Notes</CardTitle>
<CardDescription>Optional notes for this project</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add your observations and comments..."
rows={4}
/>
</CardContent>
</Card>
{/* Voting Form */}
<LiveVotingForm
projectId={cursor.activeProject.id}
onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryRoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
{ roundId },
{ enabled: !!roundId }
)
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
)
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
{round?.name || 'Round Details'}
</h1>
<p className="text-muted-foreground mt-1">
Your assigned projects for this round
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Assigned Projects</CardTitle>
</CardHeader>
<CardContent>
{!assignments || assignments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Circle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No assignments yet</p>
<p className="text-sm text-muted-foreground mt-1">
You will see your assigned projects here once they are assigned
</p>
</div>
) : (
<div className="space-y-2">
{assignments.map((assignment) => {
const isCompleted = assignment.evaluation?.status === 'SUBMITTED'
const isDraft = assignment.evaluation?.status === 'DRAFT'
return (
<Link
key={assignment.id}
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{assignment.project.title}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{assignment.project.competitionCategory && (
<Badge variant="secondary" className="text-xs">
{assignment.project.competitionCategory}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
{isCompleted ? (
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
) : isDraft ? (
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
<Clock className="mr-1 h-3 w-3" />
Draft
</Badge>
) : (
<Badge variant="outline">
<Circle className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,252 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Save, Send, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryEvaluatePage() {
const params = useParams()
const router = useRouter()
const roundId = params.roundId as string
const projectId = params.projectId as string
const [showCOIDialog, setShowCOIDialog] = useState(true)
const [coiAccepted, setCoiAccepted] = useState(false)
const [globalScore, setGlobalScore] = useState('')
const [feedbackGeneral, setFeedbackGeneral] = useState('')
const [feedbackStrengths, setFeedbackStrengths] = useState('')
const [feedbackWeaknesses, setFeedbackWeaknesses] = useState('')
const utils = trpc.useUtils()
const { data: project } = trpc.project.get.useQuery(
{ id: projectId },
{ enabled: !!projectId }
)
const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => {
utils.roundAssignment.getMyAssignments.invalidate()
toast.success('Evaluation submitted successfully')
router.push(`/jury/competitions/${roundId}` as Route)
},
onError: (err) => toast.error(err.message),
})
const handleSubmit = () => {
const score = parseInt(globalScore)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
return
}
if (!feedbackGeneral.trim() || feedbackGeneral.length < 10) {
toast.error('Please provide general feedback (minimum 10 characters)')
return
}
// In a real implementation, we would first get or create the evaluation ID
// For now, this is a placeholder that shows the structure
toast.error('Evaluation submission requires an existing evaluation ID. This feature needs backend integration.')
/* Real implementation would be:
submitMutation.mutate({
id: evaluationId, // From assignment.evaluation.id
criterionScoresJson: {}, // Criterion scores
globalScore: score,
binaryDecision: true,
feedbackText: feedbackGeneral,
})
*/
}
if (!coiAccepted && showCOIDialog) {
return (
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
<DialogDescription className="space-y-3 pt-2">
<p>
Before evaluating this project, you must confirm that you have no conflict of
interest.
</p>
<p>
A conflict of interest exists if you have a personal, professional, or financial
relationship with the project team that could influence your judgment.
</p>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-3 py-4">
<Checkbox
id="coi"
checked={coiAccepted}
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
/>
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
I confirm that I have no conflict of interest with this project and can provide an
unbiased evaluation.
</Label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
>
Cancel
</Button>
<Button
onClick={() => setShowCOIDialog(false)}
disabled={!coiAccepted}
className="bg-brand-blue hover:bg-brand-blue-light"
>
Continue to Evaluation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">
{project?.title || 'Loading...'}
</p>
</div>
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback to help the team improve.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
Provide your assessment of the project
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="globalScore">
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackGeneral">
General Feedback <span className="text-destructive">*</span>
</Label>
<Textarea
id="feedbackGeneral"
value={feedbackGeneral}
onChange={(e) => setFeedbackGeneral(e.target.value)}
placeholder="Provide your overall feedback on the project..."
rows={5}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackStrengths">Strengths</Label>
<Textarea
id="feedbackStrengths"
value={feedbackStrengths}
onChange={(e) => setFeedbackStrengths(e.target.value)}
placeholder="What are the key strengths of this project?"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="feedbackWeaknesses">Areas for Improvement</Label>
<Textarea
id="feedbackWeaknesses"
value={feedbackWeaknesses}
onChange={(e) => setFeedbackWeaknesses(e.target.value)}
placeholder="What areas could be improved?"
rows={4}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between flex-wrap gap-4">
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${projectId}` as Route)}
>
Cancel
</Button>
<div className="flex gap-3">
<Button
variant="outline"
disabled={submitMutation.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save Draft
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />
{submitMutation.isPending ? 'Submitting...' : 'Submit Evaluation'}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryProjectDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const projectId = params.projectId as string
const { data: project, isLoading } = trpc.project.get.useQuery(
{ id: projectId },
{ enabled: !!projectId }
)
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-48" />
<Skeleton className="h-64" />
</div>
)
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Project not found</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<CardTitle className="text-2xl">{project.title}</CardTitle>
{project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p>
)}
</div>
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
<Target className="mr-2 h-4 w-4" />
Evaluate Project
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Project metadata */}
<div className="flex flex-wrap gap-3">
{project.country && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{project.country}
</Badge>
)}
{project.competitionCategory && (
<Badge variant="outline">{project.competitionCategory}</Badge>
)}
{project.tags && project.tags.length > 0 && (
project.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))
)}
</div>
{/* Description */}
{project.description && (
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-muted-foreground whitespace-pre-wrap">
{project.description}
</p>
</div>
)}
{/* Team members */}
{project.teamMembers && project.teamMembers.length > 0 && (
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Users className="h-4 w-4" />
Team Members ({project.teamMembers.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{project.teamMembers.map((member: any) => (
<div
key={member.id}
className="flex items-center gap-3 p-3 rounded-lg border"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand-blue/10 text-brand-blue font-semibold text-sm">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role}
</p>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client';
import { use } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form';
import { CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
sessionId: params.sessionId
});
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Vote submitted successfully');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => {
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: session?.currentUser?.id || '',
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
});
});
};
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading session...</p>
</CardContent>
</Card>
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Session not found</p>
</CardContent>
</Card>
</div>
);
}
const hasVoted = session.currentUser?.hasVoted;
if (session.status !== 'DELIB_VOTING') {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription>
{session.round?.name} - {session.category}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">
{session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.'
: session.status === 'DELIB_TALLYING'
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
</p>
</CardContent>
</Card>
</div>
);
}
if (hasVoted) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you for your participation in this deliberation
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{session.mode === 'SINGLE_WINNER_VOTE'
? 'Select your top choice for this category.'
: 'Rank all projects from best to least preferred.'}
</p>
</CardContent>
</Card>
<DeliberationRankingForm
projects={session.projects || []}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,116 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowRight, ClipboardList, Target } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryCompetitionsPage() {
const { data: competitions, isLoading } = trpc.competition.getMyCompetitions.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
My Competitions
</h1>
<p className="text-muted-foreground mt-1">
View competitions and rounds you&apos;re assigned to
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={'/jury' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
{!competitions || competitions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div>
<h2 className="text-xl font-semibold mb-2">No Competitions</h2>
<p className="text-muted-foreground text-center max-w-md">
You don&apos;t have any active competition assignments yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{competitions.map((competition) => {
const activeRounds = competition.rounds?.filter(r => r.status !== 'ROUND_ARCHIVED') || []
const totalRounds = competition.rounds?.length || 0
return (
<Card key={competition.id} className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{competition.name}</CardTitle>
</div>
<Badge variant="secondary">
{totalRounds} round{totalRounds !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col space-y-4">
<div className="flex-1" />
<div className="space-y-2">
{activeRounds.length > 0 ? (
activeRounds.slice(0, 2).map((round) => (
<Link
key={round.id}
href={`/jury/competitions/${round.id}` as Route}
className="flex items-center justify-between p-3 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all group"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Target className="h-4 w-4 text-brand-teal shrink-0" />
<span className="text-sm font-medium truncate">{round.name}</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
</Link>
))
) : (
<p className="text-sm text-muted-foreground text-center py-2">
No active rounds
</p>
)}
{activeRounds.length > 2 && (
<p className="text-xs text-muted-foreground text-center">
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
</p>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -60,23 +60,19 @@ async function JuryDashboardContent() {
country: true,
},
},
stage: {
round: {
select: {
id: true,
name: true,
status: true,
windowOpenAt: true,
windowCloseAt: true,
track: {
competition: {
select: {
pipeline: {
program: {
select: {
program: {
select: {
name: true,
year: true,
},
},
name: true,
year: true,
},
},
},
@@ -96,7 +92,7 @@ async function JuryDashboardContent() {
},
},
orderBy: [
{ stage: { windowCloseAt: 'asc' } },
{ round: { windowCloseAt: 'asc' } },
{ createdAt: 'asc' },
],
}),
@@ -106,7 +102,7 @@ async function JuryDashboardContent() {
extendedUntil: { gte: new Date() },
},
select: {
stageId: true,
roundId: true,
extendedUntil: true,
},
}),
@@ -126,49 +122,49 @@ async function JuryDashboardContent() {
const completionRate =
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
// Group assignments by stage
const assignmentsByStage = assignments.reduce(
// Group assignments by round
const assignmentsByRound = assignments.reduce(
(acc, assignment) => {
const stageId = assignment.stage.id
if (!acc[stageId]) {
acc[stageId] = {
stage: assignment.stage,
const roundId = assignment.round.id
if (!acc[roundId]) {
acc[roundId] = {
round: assignment.round,
assignments: [],
}
}
acc[stageId].assignments.push(assignment)
acc[roundId].assignments.push(assignment)
return acc
},
{} as Record<string, { stage: (typeof assignments)[0]['stage']; assignments: typeof assignments }>
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
)
const graceByStage = new Map<string, Date>()
const graceByRound = new Map<string, Date>()
for (const gp of gracePeriods) {
const existing = graceByStage.get(gp.stageId)
const existing = graceByRound.get(gp.roundId)
if (!existing || gp.extendedUntil > existing) {
graceByStage.set(gp.stageId, gp.extendedUntil)
graceByRound.set(gp.roundId, gp.extendedUntil)
}
}
// Active stages (voting window open)
// Active rounds (voting window open)
const now = new Date()
const activeStages = Object.values(assignmentsByStage).filter(
({ stage }) =>
stage.status === 'STAGE_ACTIVE' &&
stage.windowOpenAt &&
stage.windowCloseAt &&
new Date(stage.windowOpenAt) <= now &&
new Date(stage.windowCloseAt) >= now
const activeRounds = Object.values(assignmentsByRound).filter(
({ round }) =>
round.status === 'ROUND_ACTIVE' &&
round.windowOpenAt &&
round.windowCloseAt &&
new Date(round.windowOpenAt) <= now &&
new Date(round.windowCloseAt) >= now
)
// Find next unevaluated assignment in an active stage
// Find next unevaluated assignment in an active round
const nextUnevaluated = assignments.find((a) => {
const isActive =
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
a.round.status === 'ROUND_ACTIVE' &&
a.round.windowOpenAt &&
a.round.windowCloseAt &&
new Date(a.round.windowOpenAt) <= now &&
new Date(a.round.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT'
return isActive && isIncomplete
})
@@ -176,14 +172,14 @@ async function JuryDashboardContent() {
// Recent assignments for the quick list (latest 5)
const recentAssignments = assignments.slice(0, 6)
// Get active stage remaining count
// Get active round remaining count
const activeRemaining = assignments.filter((a) => {
const isActive =
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
a.round.status === 'ROUND_ACTIVE' &&
a.round.windowOpenAt &&
a.round.windowCloseAt &&
new Date(a.round.windowOpenAt) <= now &&
new Date(a.round.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
@@ -241,7 +237,7 @@ async function JuryDashboardContent() {
</div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
<Link
href="/jury/stages"
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
>
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
@@ -253,7 +249,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/stages"
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
@@ -293,7 +289,7 @@ async function JuryDashboardContent() {
</div>
</div>
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
<Link href={`/jury/stages/${nextUnevaluated.stage.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
<Link href={`/jury/competitions/${nextUnevaluated.round.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -363,7 +359,7 @@ async function JuryDashboardContent() {
<CardTitle className="text-lg">My Assignments</CardTitle>
</div>
<Button variant="ghost" size="sm" asChild className="text-brand-teal hover:text-brand-blue">
<Link href="/jury/stages">
<Link href="/jury/competitions">
View all
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
@@ -378,11 +374,11 @@ async function JuryDashboardContent() {
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.stage.status === 'STAGE_ACTIVE' &&
assignment.stage.windowOpenAt &&
assignment.stage.windowCloseAt &&
new Date(assignment.stage.windowOpenAt) <= now &&
new Date(assignment.stage.windowCloseAt) >= now
assignment.round.status === 'ROUND_ACTIVE' &&
assignment.round.windowOpenAt &&
assignment.round.windowCloseAt &&
new Date(assignment.round.windowOpenAt) <= now &&
new Date(assignment.round.windowCloseAt) >= now
return (
<div
@@ -394,7 +390,7 @@ async function JuryDashboardContent() {
)}
>
<Link
href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}
href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}
className="flex-1 min-w-0 group"
>
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
@@ -405,7 +401,7 @@ async function JuryDashboardContent() {
{assignment.project.teamName}
</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
{assignment.stage.name}
{assignment.round.name}
</Badge>
</div>
</Link>
@@ -425,19 +421,19 @@ async function JuryDashboardContent() {
)}
{isCompleted ? (
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluation`}>
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
View
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluate`}>
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}>
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}`}>
View
</Link>
</Button>
@@ -478,7 +474,7 @@ async function JuryDashboardContent() {
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href="/jury/stages"
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
>
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
@@ -490,7 +486,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/stages"
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
>
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
@@ -509,8 +505,8 @@ async function JuryDashboardContent() {
{/* Right column */}
<div className="lg:col-span-5 space-y-4">
{/* Active Stages */}
{activeStages.length > 0 && (
{/* Active Rounds */}
{activeRounds.length > 0 && (
<AnimatedCard index={8}>
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
@@ -528,21 +524,21 @@ async function JuryDashboardContent() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{activeStages.map(({ stage, assignments: stageAssignments }) => {
const stageCompleted = stageAssignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
{activeRounds.map(({ round, assignments: roundAssignments }: { round: (typeof assignments)[0]['round']; assignments: typeof assignments }) => {
const roundCompleted = roundAssignments.filter(
(a: typeof assignments[0]) => a.evaluation?.status === 'SUBMITTED'
).length
const stageTotal = stageAssignments.length
const stageProgress =
stageTotal > 0 ? (stageCompleted / stageTotal) * 100 : 0
const isAlmostDone = stageProgress >= 80
const deadline = graceByStage.get(stage.id) ?? (stage.windowCloseAt ? new Date(stage.windowCloseAt) : null)
const roundTotal = roundAssignments.length
const roundProgress =
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
const isAlmostDone = roundProgress >= 80
const deadline = graceByRound.get(round.id) ?? (round.windowCloseAt ? new Date(round.windowCloseAt) : null)
const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
const program = stage.track.pipeline.program
const program = round.competition.program
return (
<div
key={stage.id}
key={round.id}
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent
@@ -552,7 +548,7 @@ async function JuryDashboardContent() {
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{stage.name}</h3>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{program.name} &middot; {program.year}
</p>
@@ -568,13 +564,13 @@ async function JuryDashboardContent() {
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-semibold tabular-nums">
{stageCompleted}/{stageTotal}
{roundCompleted}/{roundTotal}
</span>
</div>
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${stageProgress}%` }}
style={{ width: `${roundProgress}%` }}
/>
</div>
</div>
@@ -585,16 +581,16 @@ async function JuryDashboardContent() {
deadline={deadline}
label="Deadline:"
/>
{stage.windowCloseAt && (
{round.windowCloseAt && (
<span className="text-xs text-muted-foreground">
({formatDateOnly(stage.windowCloseAt)})
({formatDateOnly(round.windowCloseAt)})
</span>
)}
</div>
)}
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/stages/${stage.id}/assignments`}>
<Link href={`/jury/competitions/${round.id}`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -608,7 +604,7 @@ async function JuryDashboardContent() {
)}
{/* No active stages */}
{activeStages.length === 0 && (
{activeRounds.length === 0 && (
<AnimatedCard index={8}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
@@ -624,8 +620,8 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* Completion Summary by Stage */}
{Object.keys(assignmentsByStage).length > 0 && (
{/* Completion Summary by Round */}
{Object.keys(assignmentsByRound).length > 0 && (
<AnimatedCard index={9}>
<Card>
<CardHeader className="pb-3">
@@ -637,14 +633,14 @@ async function JuryDashboardContent() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{Object.values(assignmentsByStage).map(({ stage, assignments: stageAssignments }) => {
const done = stageAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
const total = stageAssignments.length
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }: { round: (typeof assignments)[0]['round']; assignments: typeof assignments }) => {
const done = roundAssignments.filter((a: typeof assignments[0]) => a.evaluation?.status === 'SUBMITTED').length
const total = roundAssignments.length
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return (
<div key={stage.id} className="space-y-2">
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{stage.name}</span>
<span className="font-medium truncate">{round.name}</span>
<div className="flex items-baseline gap-1 shrink-0 ml-2">
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
<span className="text-xs text-muted-foreground">({done}/{total})</span>

View File

@@ -1,368 +0,0 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
CheckCircle2,
Clock,
ArrowLeft,
FileEdit,
Eye,
ShieldAlert,
AlertCircle,
ClipboardList,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
// Type for assignment with included relations from stageAssignment.myAssignments
type AssignmentWithRelations = {
id: string
projectId: string
stageId: string
isCompleted: boolean
project: {
id: string
title: string
teamName: string | null
country: string | null
tags: string[]
description: string | null
}
evaluation?: {
id: string
status: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | null
} | null
conflictOfInterest?: {
id: string
hasConflict: boolean
conflictType: string | null
reviewAction: string | null
} | null
stage?: {
id: string
name: string
track: {
name: string
pipeline: { id: string; name: string }
}
}
}
function getAssignmentStatus(assignment: {
evaluation?: { status: string } | null
conflictOfInterest?: { id: string } | null
}) {
if (assignment.conflictOfInterest) return 'COI'
if (!assignment.evaluation) return 'NOT_STARTED'
return assignment.evaluation.status
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case 'SUBMITTED':
return (
<Badge variant="success" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
)
case 'DRAFT':
return (
<Badge variant="warning" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
)
case 'COI':
return (
<Badge variant="destructive" className="text-xs">
<ShieldAlert className="mr-1 h-3 w-3" />
COI Declared
</Badge>
)
default:
return (
<Badge variant="secondary" className="text-xs">
Not Started
</Badge>
)
}
}
export default function StageAssignmentsPage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const { data: stageInfo, isLoading: stageLoading } =
trpc.stage.getForJury.useQuery({ id: stageId })
const { data: rawAssignments, isLoading: assignmentsLoading } =
trpc.stageAssignment.myAssignments.useQuery({ stageId })
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
const { data: windowStatus } =
trpc.evaluation.checkStageWindow.useQuery({ stageId })
const isWindowOpen = windowStatus?.isOpen ?? false
const isLoading = stageLoading || assignmentsLoading
const totalAssignments = assignments?.length ?? 0
const completedCount = assignments?.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length ?? 0
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
const pendingCount = totalAssignments - completedCount - coiCount
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<div className="grid gap-3 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={"/jury/stages" as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Stage header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{stageInfo?.name ?? 'Stage Assignments'}
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground mt-0.5">
{stageInfo.track.name} &middot; {stageInfo.track.pipeline.name}
</p>
)}
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Summary cards */}
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold">{totalAssignments}</p>
<p className="text-xs text-muted-foreground">Total</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-red-600">{coiCount}</p>
<p className="text-xs text-muted-foreground">COI Declared</p>
</CardContent>
</Card>
</div>
{/* Assignments table */}
{assignments && assignments.length > 0 ? (
<Card>
<CardContent className="p-0">
{/* Desktop table */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Team</TableHead>
<TableHead>Country</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => {
const status = getAssignmentStatus(assignment)
return (
<TableRow key={assignment.id}>
<TableCell className="font-medium">
<Link
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
className="hover:text-brand-blue dark:hover:text-brand-teal transition-colors"
>
{assignment.project.title}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{assignment.project.teamName}
</TableCell>
<TableCell className="text-muted-foreground">
{assignment.project.country ?? '—'}
</TableCell>
<TableCell>
<StatusBadge status={status} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{status === 'SUBMITTED' ? (
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
<Eye className="mr-1 h-3 w-3" />
View
</Link>
</Button>
) : status === 'COI' ? (
<Button variant="ghost" size="sm" disabled>
<ShieldAlert className="mr-1 h-3 w-3" />
COI
</Button>
) : (
<Button
size="sm"
asChild
disabled={!isWindowOpen}
className={cn(
'bg-brand-blue hover:bg-brand-blue-light',
!isWindowOpen && 'opacity-50 pointer-events-none'
)}
>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
<FileEdit className="mr-1 h-3 w-3" />
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Mobile card list */}
<div className="md:hidden divide-y">
{assignments.map((assignment) => {
const status = getAssignmentStatus(assignment)
return (
<div key={assignment.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link
href={`/jury/stages/${stageId}/projects/${assignment.project.id}` as Route}
className="font-medium text-sm hover:text-brand-blue transition-colors"
>
{assignment.project.title}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">
{assignment.project.teamName}
{assignment.project.country && ` · ${assignment.project.country}`}
</p>
</div>
<StatusBadge status={status} />
</div>
<div className="mt-3 flex justify-end">
{status === 'SUBMITTED' ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluation` as Route}>
View Evaluation
</Link>
</Button>
) : status !== 'COI' && isWindowOpen ? (
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stageId}/projects/${assignment.project.id}/evaluate` as Route}>
{status === 'DRAFT' ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : null}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No assignments in this stage</p>
<p className="text-sm text-muted-foreground mt-1">
Assignments will appear here once an administrator assigns projects to you.
</p>
</CardContent>
</Card>
)}
{/* Window closed notice */}
{!isWindowOpen && totalAssignments > 0 && completedCount < totalAssignments && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{windowStatus?.reason ?? 'The evaluation window for this stage is currently closed.'}
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,311 +0,0 @@
'use client'
import { use, useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
GitCompare,
Star,
CheckCircle2,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { cn } from '@/lib/utils'
// Type for assignment with included relations from stageAssignment.myAssignments
type AssignmentWithRelations = {
id: string
projectId: string
stageId: string
isCompleted: boolean
project: {
id: string
title: string
teamName: string | null
country: string | null
tags: string[]
description: string | null
}
evaluation?: {
id: string
status: string
globalScore: number | null
binaryDecision: boolean | null
submittedAt: Date | null
} | null
conflictOfInterest?: {
id: string
hasConflict: boolean
conflictType: string | null
reviewAction: string | null
} | null
}
export default function StageComparePage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { data: rawAssignments, isLoading: assignmentsLoading } =
trpc.stageAssignment.myAssignments.useQuery({ stageId })
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: evaluations } =
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const criteria = stageForm?.criteriaJson?.filter(
(c: { type?: string }) => c.type !== 'section_header'
) ?? []
// Map evaluations by project ID
const evalByProject = useMemo(() => {
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
evaluations?.forEach((e) => {
if (e.assignment?.projectId) {
map.set(e.assignment.projectId, e)
}
})
return map
}, [evaluations])
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else if (next.size < 4) {
next.add(projectId)
}
return next
})
}
const selectedAssignments = assignments?.filter((a) =>
selectedIds.has(a.project.id)
) ?? []
if (assignmentsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
const submittedAssignments = assignments?.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
) ?? []
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<GitCompare className="h-6 w-6" />
Compare Projects
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Select 2-4 evaluated projects to compare side-by-side
</p>
</div>
{submittedAssignments.length < 2 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Not enough evaluations</p>
<p className="text-sm text-muted-foreground mt-1">
You need at least 2 submitted evaluations to compare projects.
</p>
</CardContent>
</Card>
) : (
<>
{/* Project selector */}
<Card>
<CardHeader>
<CardTitle className="text-lg">
Select Projects ({selectedIds.size}/4)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2">
{submittedAssignments.map((assignment) => {
const isSelected = selectedIds.has(assignment.project.id)
const eval_ = evalByProject.get(assignment.project.id)
return (
<div
key={assignment.id}
className={cn(
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
isSelected
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
: 'hover:bg-muted/50',
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
)}
onClick={() => toggleProject(assignment.project.id)}
>
<Checkbox
checked={isSelected}
disabled={selectedIds.size >= 4 && !isSelected}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{assignment.project.title}
</p>
<p className="text-xs text-muted-foreground">
{assignment.project.teamName}
</p>
</div>
{eval_?.globalScore != null && (
<Badge variant="outline" className="tabular-nums">
<Star className="mr-1 h-3 w-3 text-amber-500" />
{eval_.globalScore.toFixed(1)}
</Badge>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Comparison table */}
{selectedAssignments.length >= 2 && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]">Criterion</TableHead>
{selectedAssignments.map((a) => (
<TableHead key={a.id} className="text-center min-w-[120px]">
<div className="truncate max-w-[120px]" title={a.project.title}>
{a.project.title}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* Criterion rows */}
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
<TableRow key={criterion.id}>
<TableCell className="font-medium text-sm">
{criterion.label}
</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
const score = scores?.[criterion.id]
return (
<TableCell key={a.id} className="text-center">
{criterion.type === 'boolean' ? (
score ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
) : (
<span className="text-muted-foreground"></span>
)
) : criterion.type === 'text' ? (
<span className="text-xs truncate max-w-[100px] block">
{String(score ?? '—')}
</span>
) : (
<span className="tabular-nums font-semibold">
{typeof score === 'number' ? score.toFixed(1) : '—'}
</span>
)}
</TableCell>
)
})}
</TableRow>
))}
{/* Global score row */}
<TableRow className="bg-muted/50 font-semibold">
<TableCell>Global Score</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
return (
<TableCell key={a.id} className="text-center">
<span className="tabular-nums text-lg">
{eval_?.globalScore?.toFixed(1) ?? '—'}
</span>
</TableCell>
)
})}
</TableRow>
{/* Decision row */}
<TableRow>
<TableCell className="font-medium">Decision</TableCell>
{selectedAssignments.map((a) => {
const eval_ = evalByProject.get(a.project.id)
return (
<TableCell key={a.id} className="text-center">
{eval_?.binaryDecision != null ? (
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
{eval_.binaryDecision ? 'Yes' : 'No'}
</Badge>
) : (
'—'
)}
</TableCell>
)
})}
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
)}
</div>
)
}

View File

@@ -1,269 +0,0 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageJuryLivePage({
params,
}: {
params: Promise<{ stageId: string }>
}) {
const { stageId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
// Get live cursor for this stage
const { data: cursorData } = trpc.live.getCursor.useQuery(
{ stageId },
{ enabled: !!stageId }
)
const sessionId = cursorData?.sessionId ?? null
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Reset vote state when active project changes
const activeProjectId = activeProject?.id
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Vote submitted!')
setHasVoted(true)
setSelectedScore(null)
setLastVotedProjectId(activeProjectId ?? null)
},
onError: (err) => {
toast.error(err.message)
},
})
const handleVote = () => {
if (!sessionId || !activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
if (!cursorData && !stageInfo) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!sessionId) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No live session active</p>
<p className="text-sm text-muted-foreground mt-1">
A live presentation session has not been started for this stage.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header with connection status */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
<div className="flex items-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Connected
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
{!isConnected && (
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
</CardContent>
</Card>
)}
{/* Paused overlay */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Pause className="h-12 w-12 text-amber-600 mb-3" />
<p className="text-lg font-semibold">Session Paused</p>
<p className="text-sm text-muted-foreground mt-1">
The session administrator has paused voting. Please wait...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card>
<CardHeader>
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500" />
Cast Your Vote
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{hasVoted ? (
<div className="flex flex-col items-center py-6 text-center">
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
<p className="font-semibold">Vote submitted!</p>
<p className="text-sm text-muted-foreground mt-1">
Waiting for the next project...
</p>
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-12 text-lg font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full bg-brand-blue hover:bg-brand-blue-light"
size="lg"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Waiting for next project...</p>
<p className="text-sm text-muted-foreground mt-1">
The session administrator will advance to the next project.
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,199 +0,0 @@
'use client'
import { use, useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import Link from 'next/link'
import type { Route } from 'next'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, AlertCircle } from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
export default function StageEvaluatePage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
// Fetch assignment details
const { data: assignment, isLoading: assignmentLoading } =
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
// Fetch stage info for breadcrumb
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
// Fetch or create evaluation draft
const startEval = trpc.evaluation.startStage.useMutation()
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
// State for the evaluation returned by the mutation
const [evaluation, setEvaluation] = useState<{
id: string
status: string
criterionScoresJson?: unknown
globalScore?: number | null
binaryDecision?: boolean | null
feedbackText?: string | null
} | null>(null)
// Start evaluation on first load if we have the assignment
useEffect(() => {
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
startEval.mutate(
{ assignmentId: assignment.id, stageId },
{ onSuccess: (data) => setEvaluation(data) }
)
}
}, [assignment?.id, windowStatus?.isOpen])
const isLoading = assignmentLoading || startEval.isPending
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!assignment) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
<p className="font-medium text-destructive">Assignment not found</p>
<p className="text-sm text-muted-foreground mt-1">
You don&apos;t have an assignment for this project in this stage.
</p>
</CardContent>
</Card>
</div>
)
}
const isWindowOpen = windowStatus?.isOpen ?? false
const criteria = stageForm?.criteriaJson ?? []
// Get COI status from assignment
const coiStatus = {
hasConflict: !!assignment.conflictOfInterest,
declared: !!assignment.conflictOfInterest,
}
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Project title + stage window */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{assignment.project.title}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{assignment.project.teamName}
{assignment.project.country && ` · ${assignment.project.country}`}
</p>
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Grace period notice */}
{windowStatus?.hasGracePeriod && windowStatus?.graceExpiresAt && (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
You are in a grace period. Please submit your evaluation before{' '}
{new Date(windowStatus.graceExpiresAt).toLocaleString()}.
</p>
</CardContent>
</Card>
)}
{/* Window closed notice */}
{!isWindowOpen && !windowStatus?.hasGracePeriod && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
{windowStatus?.reason ?? 'The evaluation window for this stage is closed.'}
</p>
</CardContent>
</Card>
)}
{/* Project files */}
<CollapsibleFilesSection
projectId={projectId}
fileCount={assignment.project.files?.length ?? 0}
stageId={stageId}
/>
{/* Evaluation form */}
{isWindowOpen || windowStatus?.hasGracePeriod ? (
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id ?? null}
projectTitle={assignment.project.title}
criteria={criteria as Array<{ id: string; label: string; description?: string; type?: 'numeric' | 'text' | 'boolean' | 'section_header'; scale?: number; weight?: number; required?: boolean }>}
initialData={
evaluation
? {
criterionScoresJson:
evaluation.criterionScoresJson as Record<string, number | string | boolean> | null,
globalScore: evaluation.globalScore ?? null,
binaryDecision: evaluation.binaryDecision ?? null,
feedbackText: evaluation.feedbackText ?? null,
status: evaluation.status,
}
: undefined
}
isVotingOpen={isWindowOpen || !!windowStatus?.hasGracePeriod}
deadline={
windowStatus?.graceExpiresAt
? new Date(windowStatus.graceExpiresAt)
: stageInfo?.windowCloseAt
? new Date(stageInfo.windowCloseAt)
: null
}
coiStatus={coiStatus}
/>
) : null}
</div>
)
}

View File

@@ -1,235 +0,0 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft,
CheckCircle2,
Star,
MessageSquare,
Clock,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
export default function ViewStageEvaluationPage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
const { data: evaluations, isLoading } =
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
const evaluation = evaluations?.[0] // Most recent evaluation
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!evaluation) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">No evaluation found</p>
<p className="text-sm text-muted-foreground mt-1">
You haven&apos;t submitted an evaluation for this project yet.
</p>
</CardContent>
</Card>
</div>
)
}
const criterionScores = evaluation.criterionScoresJson as Record<string, number | string | boolean> | null
const criteria = (stageForm?.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number }>) ?? []
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{evaluation.assignment?.project?.title ?? 'Evaluation'}
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Submitted evaluation read only
</p>
</div>
<Badge variant="success" className="self-start">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
</div>
{/* Submission info */}
{evaluation.submittedAt && (
<Card>
<CardContent className="flex items-center gap-2 py-3">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Submitted on{' '}
{new Date(evaluation.submittedAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</CardContent>
</Card>
)}
{/* Criterion scores */}
{criteria.length > 0 && criterionScores && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criterion Scores</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{criteria.map((criterion) => {
const score = criterionScores[criterion.id]
if (criterion.type === 'section_header') {
return (
<div key={criterion.id} className="pt-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{criterion.label}
</h3>
<Separator className="mt-2" />
</div>
)
}
return (
<div key={criterion.id} className="flex items-center justify-between py-2">
<span className="text-sm font-medium">{criterion.label}</span>
<div className="flex items-center gap-2">
{criterion.type === 'boolean' ? (
<Badge variant={score ? 'success' : 'secondary'}>
{score ? 'Yes' : 'No'}
</Badge>
) : criterion.type === 'text' ? (
<span className="text-sm text-muted-foreground max-w-[200px] truncate">
{String(score ?? '—')}
</span>
) : (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="font-semibold tabular-nums">
{typeof score === 'number' ? score.toFixed(1) : '—'}
</span>
{criterion.scale && (
<span className="text-xs text-muted-foreground">
/ {criterion.scale}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Global score + Decision */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500" />
Global Score
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl font-bold tabular-nums">
{evaluation.globalScore?.toFixed(1) ?? '—'}
</p>
</CardContent>
</Card>
{evaluation.binaryDecision !== null && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Decision</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant={evaluation.binaryDecision ? 'success' : 'destructive'}
className="text-lg px-4 py-2"
>
{evaluation.binaryDecision ? 'Recommend' : 'Do Not Recommend'}
</Badge>
</CardContent>
</Card>
)}
</div>
{/* Feedback */}
{evaluation.feedbackText && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Feedback
</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -1,217 +0,0 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import type { Route } from 'next'
import Link from 'next/link'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
FileEdit,
Eye,
Users,
MapPin,
Tag,
AlertCircle,
} from 'lucide-react'
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
function EvalStatusCard({
status,
stageId,
projectId,
isWindowOpen,
}: {
status: string
stageId: string
projectId: string
isWindowOpen: boolean
}) {
const isSubmitted = status === 'SUBMITTED'
const isDraft = status === 'DRAFT'
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge
variant={
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
}
>
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
</Badge>
<div className="flex gap-2">
{isSubmitted ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
<Eye className="mr-1 h-3 w-3" />
View Evaluation
</Link>
</Button>
) : isWindowOpen ? (
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
<FileEdit className="mr-1 h-3 w-3" />
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled>
Window Closed
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}
export default function StageProjectDetailPage({
params,
}: {
params: Promise<{ stageId: string; projectId: string }>
}) {
const { stageId, projectId } = use(params)
const { data: assignment, isLoading: assignmentLoading } =
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
const isWindowOpen = windowStatus?.isOpen ?? false
if (assignmentLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!assignment) {
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
<p className="font-medium text-destructive">Assignment not found</p>
</CardContent>
</Card>
</div>
)
}
const project = assignment.project
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
return (
<div className="space-y-4">
{/* Back + Breadcrumb */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{stageInfo && (
<StageBreadcrumb
pipelineName={stageInfo.track.pipeline.name}
trackName={stageInfo.track.name}
stageName={stageInfo.name}
stageId={stageId}
/>
)}
</div>
{/* Project header */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
{project.teamName && (
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{project.teamName}
</span>
)}
{project.country && (
<span className="flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{project.country}
</span>
)}
</div>
</div>
<StageWindowBadge
windowOpenAt={stageInfo?.windowOpenAt}
windowCloseAt={stageInfo?.windowCloseAt}
status={stageInfo?.status}
/>
</div>
{/* Project description */}
{project.description && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</CardContent>
</Card>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="h-4 w-4 text-muted-foreground" />
{project.tags.map((tag: string) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Evaluation status */}
<EvalStatusCard
status={evalStatus}
stageId={stageId}
projectId={projectId}
isWindowOpen={isWindowOpen}
/>
{/* Project files */}
<CollapsibleFilesSection
projectId={projectId}
fileCount={project.files?.length ?? 0}
stageId={stageId}
/>
</div>
)
}

View File

@@ -1,247 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import Link from 'next/link'
import type { Route } from 'next'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import {
ClipboardList,
CheckCircle2,
Clock,
ArrowRight,
BarChart3,
Target,
Layers,
} from 'lucide-react'
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
import { cn } from '@/lib/utils'
export default function JuryStagesDashboard() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id ?? ''
const { data: stages, isLoading: stagesLoading } =
trpc.stageAssignment.myStages.useQuery(
{ programId },
{ enabled: !!programId }
)
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
const totalPending = totalAssignments - totalCompleted - totalInProgress
const completionRate = totalAssignments > 0
? Math.round((totalCompleted / totalAssignments) * 100)
: 0
const stats = [
{
label: 'Total Assignments',
value: totalAssignments,
icon: ClipboardList,
accentColor: 'border-l-blue-500',
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
iconColor: 'text-blue-600 dark:text-blue-400',
},
{
label: 'Completed',
value: totalCompleted,
icon: CheckCircle2,
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
iconColor: 'text-emerald-600 dark:text-emerald-400',
},
{
label: 'In Progress',
value: totalInProgress,
icon: Clock,
accentColor: 'border-l-amber-500',
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
iconColor: 'text-amber-600 dark:text-amber-400',
},
{
label: 'Pending',
value: totalPending,
icon: Target,
accentColor: 'border-l-slate-400',
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
iconColor: 'text-slate-500 dark:text-slate-400',
},
]
if (stagesLoading) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted">
<CardContent className="flex items-center gap-4 py-5 px-5">
<Skeleton className="h-11 w-11 rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-7 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardContent className="py-5">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (!stages || stages.length === 0) {
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
<Layers className="h-8 w-8 text-brand-teal/60" />
</div>
<p className="text-lg font-semibold">No stage assignments yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
Your stage-based assignments will appear here once an administrator assigns projects to you.
</p>
<Button variant="outline" asChild className="mt-4">
<Link href="/jury">
Back to Dashboard
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
</div>
{/* Stats row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat) => (
<Card
key={stat.label}
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
>
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className={cn('rounded-xl p-3', stat.iconBg)}>
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
</div>
<div>
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
</div>
</CardContent>
</Card>
))}
<Card className="border-l-4 border-l-brand-teal">
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
</div>
<div className="flex-1 min-w-0">
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
{completionRate}%
</p>
<Progress value={completionRate} className="h-1.5 mt-1" />
</div>
</CardContent>
</Card>
</div>
{/* Stage cards */}
<div className="space-y-3">
{stages.map((stage) => {
const stageProgress = stage.stats.total > 0
? Math.round((stage.stats.completed / stage.stats.total) * 100)
: 0
return (
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="py-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
<StageWindowBadge
windowOpenAt={stage.windowOpenAt}
windowCloseAt={stage.windowCloseAt}
status={stage.status}
/>
</div>
<p className="text-sm text-muted-foreground">
{stage.track.name} &middot; {stage.track.pipeline.name}
</p>
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-semibold tabular-nums">
{stage.stats.completed}/{stage.stats.total}
</span>
</div>
<Progress value={stageProgress} className="h-2" />
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end">
<div className="flex gap-2 flex-wrap">
{stage.stats.completed > 0 && (
<Badge variant="success" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
{stage.stats.completed} done
</Badge>
)}
{stage.stats.inProgress > 0 && (
<Badge variant="warning" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
{stage.stats.inProgress} in progress
</Badge>
)}
</div>
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/stages/${stage.id}/assignments` as Route}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { WorkspaceChat } from '@/components/mentor/workspace-chat'
import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel'
import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react'
import { toast } from 'sonner'
export default function MentorWorkspaceDetailPage() {
const params = useParams()
const projectId = params.projectId as string
// Get mentor assignment for this project
const { data: assignments } = trpc.mentor.getMyProjects.useQuery()
const assignment = assignments?.find(a => a.projectId === projectId)
const { data: project, isLoading } = trpc.project.get.useQuery(
{ id: projectId },
{ enabled: !!projectId }
)
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96" />
</div>
)
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={'/mentor/workspace' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Project not found</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/mentor/workspace' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div className="flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
{project.status && (
<Badge variant="secondary">{project.status}</Badge>
)}
</div>
{project.teamName && (
<p className="text-muted-foreground mt-1">{project.teamName}</p>
)}
</div>
</div>
<Tabs defaultValue="chat" className="w-full">
<TabsList className="w-full sm:w-auto grid grid-cols-3 sm:inline-grid">
<TabsTrigger value="chat" className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
<span className="hidden sm:inline">Chat</span>
</TabsTrigger>
<TabsTrigger value="files" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">Files</span>
</TabsTrigger>
<TabsTrigger value="promotion" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Promotion</span>
</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="mt-6">
{assignment ? (
<WorkspaceChat mentorAssignmentId={assignment.id} />
) : (
<Card>
<CardContent className="text-center py-8">
<MessageSquare className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Loading workspace...</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="files" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Workspace Files</CardTitle>
<CardDescription>
Files shared in the mentor workspace
</CardDescription>
</CardHeader>
<CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
File listing feature coming soon
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="promotion" className="mt-6">
{assignment ? (
<FilePromotionPanel mentorAssignmentId={assignment.id} />
) : (
<Card>
<CardContent className="text-center py-8">
<Upload className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Loading workspace...</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, ArrowRight, Users, Briefcase } from 'lucide-react'
import { toast } from 'sonner'
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
SEMIFINALIST: 'default',
FINALIST: 'default',
WINNER: 'default',
REJECTED: 'destructive',
}
export default function MentorWorkspacePage() {
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
const projects = assignments || []
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mentor Workspace</h1>
<p className="text-muted-foreground mt-1">
Collaborate with your assigned mentee projects
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={'/mentor' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>
</Button>
</div>
{projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
<Briefcase className="h-8 w-8 text-brand-teal/60" />
</div>
<h2 className="text-xl font-semibold mb-2">No Projects Assigned</h2>
<p className="text-muted-foreground text-center max-w-md">
You don&apos;t have any mentee projects assigned yet. Check back later.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((assignment) => {
const project = assignment.project
const teamSize = project.teamMembers?.length || 0
return (
<Card
key={assignment.id}
className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate" title={project.title}>
{project.title}
</CardTitle>
{project.teamName && (
<p className="text-sm text-muted-foreground mt-1 truncate">
{project.teamName}
</p>
)}
</div>
{project.status && (
<Badge variant={statusColors[project.status] || 'secondary'} className="shrink-0 ml-2">
{project.status}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col space-y-4">
{project.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
<div className="flex-1" />
<div className="flex items-center justify-between text-sm pt-3 border-t">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{teamSize} member{teamSize !== 1 ? 's' : ''}</span>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/mentor/workspace/${project.id}` as Route}>
Open
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -54,11 +54,11 @@ import {
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { stageId: value }
return { roundId: value }
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
@@ -72,7 +72,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
) || []
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
@@ -101,7 +101,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
}
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'STAGE_ACTIVE').length
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0
return (
@@ -341,7 +341,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -462,19 +462,19 @@ function CrossStageTab() {
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
@@ -492,15 +492,15 @@ function CrossStageTab() {
{stages.map((stage) => (
<Badge
key={stage.id}
variant={selectedStageIds.includes(stage.id) ? 'default' : 'outline'}
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleStage(stage.id)}
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
</Badge>
))}
</div>
{selectedStageIds.length < 2 && (
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
@@ -508,11 +508,11 @@ function CrossStageTab() {
</CardContent>
</Card>
{comparisonLoading && selectedStageIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
stageId: string; stageName: string; projectCount: number; evaluationCount: number
roundId: string; roundName: string; projectCount: number; evaluationCount: number
completionRate: number; averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
@@ -523,7 +523,7 @@ function CrossStageTab() {
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -551,7 +551,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
function DiversityTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -595,7 +595,7 @@ export default function ObserverReportsPage() {
}
const hasSelection = !!selectedValue
const selectedStage = stages.find((s) => s.id === selectedValue)
const selectedRound = stages.find((s) => s.id === selectedValue)
return (
<div className="space-y-6">
@@ -662,9 +662,9 @@ export default function ObserverReportsPage() {
</TabsList>
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton
stageId={selectedValue}
roundName={selectedStage?.name}
programName={selectedStage?.programName}
roundId={selectedValue}
roundName={selectedRound?.name}
programName={selectedRound?.programName}
/>
)}
</div>

View File

@@ -46,16 +46,16 @@ export default function StageApplyPage() {
return (
<ApplyWizardDynamic
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
programName={config.program.name}
programYear={config.program.year}
stageId={config.stage.id}
roundId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
submissionDeadline={config.stage.submissionDeadline}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'stage',
stageId: config.stage.id,
roundId: config.stage.id,
data: data as any,
})
}}

View File

@@ -46,7 +46,7 @@ export default function EditionApplyPage() {
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
config={{...DEFAULT_WIZARD_CONFIG, ...config.wizardConfig}}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}

View File

@@ -1,267 +0,0 @@
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}

View File

@@ -337,7 +337,7 @@ export function SubmissionDetailClient() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div

View File

@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
{data.session.round?.competition.program.name} - {data.session.round?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
{data.session.round?.competition.program.name} - {data.session.round?.name}
</CardDescription>
</CardHeader>

View File

@@ -0,0 +1,88 @@
'use client';
import { use, useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import { Card, CardContent } from '@/components/ui/card';
import { AudienceVoteCard } from '@/components/public/audience-vote-card';
import { toast } from 'sonner';
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise);
const utils = trpc.useUtils();
const [hasVoted, setHasVoted] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId });
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
setHasVoted(true);
// Store in localStorage to prevent duplicate votes
if (cursor?.activeProject?.id) {
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
}
toast.success('Vote submitted! Thank you for participating.');
},
onError: (err) => {
toast.error(err.message);
}
});
// Check localStorage on mount
useEffect(() => {
if (cursor?.activeProject?.id) {
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`);
if (voted === 'true') {
setHasVoted(true);
}
}
}, [cursor?.activeProject?.id, params.roundId]);
const handleVote = () => {
if (!cursor?.activeProject?.id) return;
submitVoteMutation.mutate({
projectId: cursor.activeProject.id,
sessionId: params.roundId,
score: 1,
token: `audience-${Date.now()}`
});
};
if (!cursor?.activeProject) {
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-center text-lg text-muted-foreground">
No project is currently being presented
</p>
<p className="mt-2 text-center text-sm text-muted-foreground">
Please wait for the ceremony to begin
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4">
<div className="w-full">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1>
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p>
</div>
<AudienceVoteCard
project={cursor.activeProject}
onVote={handleVote}
hasVoted={hasVoted}
/>
<p className="mt-6 text-center text-sm text-muted-foreground">
Live voting in progress
</p>
</div>
</div>
);
}

View File

@@ -1,215 +0,0 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}

View File

@@ -1,216 +0,0 @@
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const POLL_INTERVAL_MS = 2000
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const { sessionId } = await params
// Validate session exists
const cursor = await prisma.liveProgressCursor.findUnique({
where: { sessionId },
})
if (!cursor) {
return new Response('Session not found', { status: 404 })
}
// Manually fetch related data since LiveProgressCursor doesn't have these relations
let activeProject = null
if (cursor.activeProjectId) {
activeProject = await prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
const stageInfo = await prisma.stage.findUnique({
where: { id: cursor.stageId },
select: { id: true, name: true },
})
const encoder = new TextEncoder()
let intervalId: ReturnType<typeof setInterval> | null = null
const stream = new ReadableStream({
start(controller) {
// Send initial state
type CohortWithProjects = Awaited<ReturnType<typeof prisma.cohort.findMany<{
where: { stageId: string }
include: { projects: { select: { projectId: true } } }
}>>>
const cohortPromise: Promise<CohortWithProjects> = prisma.cohort
.findMany({
where: { stageId: cursor.stageId },
include: {
projects: {
select: { projectId: true },
},
},
})
.then((cohorts) => {
const initData = {
activeProject,
isPaused: cursor.isPaused,
stageInfo,
openCohorts: cohorts.map((c) => ({
id: c.id,
name: c.name,
isOpen: c.isOpen,
projectIds: c.projects.map((p) => p.projectId),
})),
}
controller.enqueue(
encoder.encode(`event: init\ndata: ${JSON.stringify(initData)}\n\n`)
)
return cohorts
})
.catch((): CohortWithProjects => {
// Ignore errors on init
return []
})
cohortPromise.then((initialCohorts: CohortWithProjects) => {
// Poll for updates
let lastActiveProjectId = cursor.activeProjectId
let lastIsPaused = cursor.isPaused
let lastCohortState = JSON.stringify(
(initialCohorts ?? []).map((c: { id: string; isOpen: boolean; windowOpenAt: Date | null; windowCloseAt: Date | null }) => ({
id: c.id,
isOpen: c.isOpen,
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
}))
)
intervalId = setInterval(async () => {
try {
const updated = await prisma.liveProgressCursor.findUnique({
where: { sessionId },
})
if (!updated) {
controller.enqueue(
encoder.encode(
`event: session.ended\ndata: ${JSON.stringify({ reason: 'Session removed' })}\n\n`
)
)
controller.close()
if (intervalId) clearInterval(intervalId)
return
}
// Check for cursor changes
if (
updated.activeProjectId !== lastActiveProjectId ||
updated.isPaused !== lastIsPaused
) {
// Fetch updated active project if changed
let updatedActiveProject = null
if (updated.activeProjectId) {
updatedActiveProject = await prisma.project.findUnique({
where: { id: updated.activeProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
controller.enqueue(
encoder.encode(
`event: cursor.updated\ndata: ${JSON.stringify({
activeProject: updatedActiveProject,
isPaused: updated.isPaused,
})}\n\n`
)
)
// Check pause/resume transitions
if (updated.isPaused && !lastIsPaused) {
controller.enqueue(encoder.encode(`event: session.paused\ndata: {}\n\n`))
} else if (!updated.isPaused && lastIsPaused) {
controller.enqueue(encoder.encode(`event: session.resumed\ndata: {}\n\n`))
}
lastActiveProjectId = updated.activeProjectId
lastIsPaused = updated.isPaused
}
// Poll cohort changes
const currentCohorts = await prisma.cohort.findMany({
where: { stageId: cursor.stageId },
select: {
id: true,
name: true,
isOpen: true,
windowOpenAt: true,
windowCloseAt: true,
projects: { select: { projectId: true } },
},
})
const currentCohortState = JSON.stringify(
currentCohorts.map((c) => ({
id: c.id,
isOpen: c.isOpen,
windowOpenAt: c.windowOpenAt?.toISOString() ?? null,
windowCloseAt: c.windowCloseAt?.toISOString() ?? null,
}))
)
if (currentCohortState !== lastCohortState) {
controller.enqueue(
encoder.encode(
`event: cohort.window.changed\ndata: ${JSON.stringify({
openCohorts: currentCohorts.map((c) => ({
id: c.id,
name: c.name,
isOpen: c.isOpen,
projectIds: c.projects.map((p) => p.projectId),
})),
})}\n\n`
)
)
lastCohortState = currentCohortState
}
// Send heartbeat to keep connection alive
controller.enqueue(encoder.encode(`: heartbeat\n\n`))
} catch {
// Connection may be closed, ignore errors
}
}, POLL_INTERVAL_MS)
})
},
cancel() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
},
})
// Check if client disconnected
request.signal.addEventListener('abort', () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import { AlertTriangle, Bot, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
interface AssignmentPreviewSheetProps {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AssignmentPreviewSheet({
roundId,
open,
onOpenChange,
}: AssignmentPreviewSheetProps) {
const utils = trpc.useUtils()
const {
data: preview,
isLoading,
refetch,
} = trpc.roundAssignment.preview.useQuery(
{ roundId, honorIntents: true, requiredReviews: 3 },
{ enabled: open }
)
const { mutate: execute, isPending: isExecuting } = trpc.roundAssignment.execute.useMutation({
onSuccess: (result) => {
toast.success(`Created ${result.created} assignments`)
utils.roundAssignment.coverageReport.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
onOpenChange(false)
},
onError: (err) => {
toast.error(err.message)
},
})
useEffect(() => {
if (open) {
refetch()
}
}, [open, refetch])
const handleExecute = () => {
if (!preview?.assignments || preview.assignments.length === 0) {
toast.error('No assignments to execute')
return
}
execute({
roundId,
assignments: preview.assignments.map((a: any) => ({
userId: a.userId,
projectId: a.projectId,
})),
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>Assignment Preview</SheetTitle>
<SheetDescription className="flex items-center gap-2">
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Bot className="h-3 w-3" />
AI Suggested
</Badge>
Review the proposed assignments before executing. All assignments are admin-approved on execute.
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-200px)] mt-6">
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : preview ? (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
{preview.stats.assignmentsGenerated || 0} Assignments Proposed
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{preview.stats.totalJurors || 0} jurors will receive assignments
</p>
</CardContent>
</Card>
{preview.warnings && preview.warnings.length > 0 && (
<Card className="border-amber-500">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600" />
Warnings ({preview.warnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{preview.warnings.map((warning: string, idx: number) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-amber-600"></span>
<span>{warning}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{preview.assignments && preview.assignments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Assignment Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Total assignments:</span>
<span className="font-medium">{preview.assignments.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Unique projects:</span>
<span className="font-medium">
{new Set(preview.assignments.map((a: any) => a.projectId)).size}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Unique jurors:</span>
<span className="font-medium">
{new Set(preview.assignments.map((a: any) => a.userId)).size}
</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">No preview data available</p>
)}
</ScrollArea>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={isExecuting || !preview?.assignments || preview.assignments.length === 0}
>
{isExecuting ? 'Executing...' : 'Execute Assignments'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
interface CoverageReportProps {
roundId: string
}
export function CoverageReport({ roundId }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery({
roundId,
requiredReviews: 3,
})
if (isLoading) {
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
)
}
if (!coverage) {
return <p className="text-muted-foreground">No coverage data available</p>
}
const totalAssigned = coverage.fullyAssigned || 0
const totalProjects = coverage.totalProjects || 0
const avgPerJuror = coverage.avgReviewsPerProject?.toFixed(1) || '0'
const unassignedCount = coverage.unassigned || 0
return (
<div className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Assigned</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssigned}</div>
<p className="text-xs text-muted-foreground">
{totalProjects > 0
? `${((totalAssigned / totalProjects) * 100).toFixed(1)}% coverage`
: 'No projects'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Per Juror</CardTitle>
<Users className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{avgPerJuror}</div>
<p className="text-xs text-muted-foreground">Assignments per juror</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{unassignedCount}</div>
<p className="text-xs text-muted-foreground">
Projects below 3 reviews
</p>
</CardContent>
</Card>
</div>
{coverage.unassigned > 0 && (
<Card className="border-amber-500">
<CardHeader>
<CardTitle className="text-sm">Coverage Warnings</CardTitle>
<CardDescription>Issues detected in assignment coverage</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
<li className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<span>{coverage.unassigned} projects have insufficient coverage</span>
</li>
</ul>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
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'
const roundTypeColors: Record<string, string> = {
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 roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
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' },
}
type RoundSummary = {
id: string
name: string
slug: string
roundType: string
status: string
sortOrder: number
windowOpenAt: Date | string | null
windowCloseAt: Date | string | null
}
export function CompetitionTimeline({
competitionId,
rounds,
}: {
competitionId: string
rounds: RoundSummary[]
}) {
if (rounds.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured yet. Add rounds to see the competition timeline.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Round Timeline</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop: horizontal timeline */}
<div className="hidden md:block overflow-x-auto pb-2">
<div className="flex items-start gap-0 min-w-max">
{rounds.map((round, index) => {
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const StatusIcon = statusCfg.icon
const isLast = index === rounds.length - 1
return (
<div key={round.id} className="flex items-start">
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
className="group flex flex-col items-center text-center w-32 shrink-0"
>
<div className="relative">
<StatusIcon className={cn('h-6 w-6', statusCfg.color)} />
</div>
<p className="mt-2 text-xs font-medium group-hover:text-primary transition-colors line-clamp-2">
{round.name}
</p>
<Badge
variant="secondary"
className={cn(
'mt-1 text-[9px]',
roundTypeColors[round.roundType] ?? ''
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
{round.windowOpenAt && (
<p className="mt-1 text-[10px] text-muted-foreground">
{format(new Date(round.windowOpenAt), 'MMM d')}
{round.windowCloseAt && (
<> - {format(new Date(round.windowCloseAt), 'MMM d')}</>
)}
</p>
)}
</Link>
{!isLast && (
<div className="mt-3 h-px w-8 bg-border shrink-0" />
)}
</div>
)
})}
</div>
</div>
{/* Mobile: vertical timeline */}
<div className="md:hidden space-y-0">
{rounds.map((round, index) => {
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const StatusIcon = statusCfg.icon
const isLast = index === rounds.length - 1
return (
<div key={round.id}>
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
>
<div className="flex flex-col items-center shrink-0">
<StatusIcon className={cn('h-5 w-5', statusCfg.color)} />
{!isLast && <div className="w-px flex-1 bg-border mt-1 min-h-[16px]" />}
</div>
<div className="flex-1 min-w-0 pb-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{round.name}</p>
<Badge
variant="secondary"
className={cn(
'text-[9px] shrink-0',
roundTypeColors[round.roundType] ?? ''
)}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
{round.windowOpenAt && (
<p className="text-[11px] text-muted-foreground mt-0.5">
{format(new Date(round.windowOpenAt), 'MMM d, yyyy')}
{round.windowCloseAt && (
<> - {format(new Date(round.windowCloseAt), 'MMM d, yyyy')}</>
)}
</p>
)}
</div>
</Link>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { Card, CardContent, 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'
type RoundConfigFormProps = {
roundType: string
config: Record<string, unknown>
onChange: (config: Record<string, unknown>) => void
}
export function RoundConfigForm({ roundType, config, onChange }: RoundConfigFormProps) {
const updateConfig = (key: string, value: unknown) => {
onChange({ ...config, [key]: value })
}
if (roundType === 'INTAKE') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Intake Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="allowDrafts">Allow Drafts</Label>
<Switch
id="allowDrafts"
checked={(config.allowDrafts as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('allowDrafts', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="draftExpiryDays">Draft Expiry (days)</Label>
<Input
id="draftExpiryDays"
type="number"
min={1}
value={(config.draftExpiryDays as number) ?? 30}
onChange={(e) => updateConfig('draftExpiryDays', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFileSizeMB">Max File Size (MB)</Label>
<Input
id="maxFileSizeMB"
type="number"
min={1}
value={(config.maxFileSizeMB as number) ?? 50}
onChange={(e) => updateConfig('maxFileSizeMB', parseInt(e.target.value, 10))}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="publicFormEnabled">Public Form Enabled</Label>
<Switch
id="publicFormEnabled"
checked={(config.publicFormEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('publicFormEnabled', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'FILTERING') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Filtering Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="aiScreeningEnabled">AI Screening</Label>
<Switch
id="aiScreeningEnabled"
checked={(config.aiScreeningEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('aiScreeningEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="duplicateDetectionEnabled">Duplicate Detection</Label>
<Switch
id="duplicateDetectionEnabled"
checked={(config.duplicateDetectionEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('duplicateDetectionEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="manualReviewEnabled">Manual Review</Label>
<Switch
id="manualReviewEnabled"
checked={(config.manualReviewEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('manualReviewEnabled', checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="batchSize">Batch Size</Label>
<Input
id="batchSize"
type="number"
min={1}
value={(config.batchSize as number) ?? 20}
onChange={(e) => updateConfig('batchSize', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'EVALUATION') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Evaluation Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="requiredReviews">Required Reviews per Project</Label>
<Input
id="requiredReviews"
type="number"
min={1}
value={(config.requiredReviewsPerProject as number) ?? 3}
onChange={(e) => updateConfig('requiredReviewsPerProject', parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={(config.scoringMode as string) ?? 'criteria'}
onValueChange={(value) => updateConfig('scoringMode', value)}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="criteria">Criteria-based</SelectItem>
<SelectItem value="global">Global score</SelectItem>
<SelectItem value="binary">Binary (pass/fail)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="requireFeedback">Require Feedback</Label>
<Switch
id="requireFeedback"
checked={(config.requireFeedback as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('requireFeedback', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="coiRequired">COI Declaration Required</Label>
<Switch
id="coiRequired"
checked={(config.coiRequired as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('coiRequired', checked)}
/>
</div>
</CardContent>
</Card>
)
}
if (roundType === 'LIVE_FINAL') {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Live Final Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="juryVotingEnabled">Jury Voting</Label>
<Switch
id="juryVotingEnabled"
checked={(config.juryVotingEnabled as boolean) ?? true}
onCheckedChange={(checked) => updateConfig('juryVotingEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="audienceVotingEnabled">Audience Voting</Label>
<Switch
id="audienceVotingEnabled"
checked={(config.audienceVotingEnabled as boolean) ?? false}
onCheckedChange={(checked) => updateConfig('audienceVotingEnabled', checked)}
/>
</div>
{(config.audienceVotingEnabled as boolean) && (
<div className="space-y-2">
<Label htmlFor="audienceVoteWeight">Audience Vote Weight (0-1)</Label>
<Input
id="audienceVoteWeight"
type="number"
min={0}
max={1}
step={0.1}
value={(config.audienceVoteWeight as number) ?? 0}
onChange={(e) => updateConfig('audienceVoteWeight', parseFloat(e.target.value))}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="presentationDuration">Presentation Duration (min)</Label>
<Input
id="presentationDuration"
type="number"
min={1}
value={(config.presentationDurationMinutes as number) ?? 15}
onChange={(e) => updateConfig('presentationDurationMinutes', parseInt(e.target.value, 10))}
/>
</div>
</CardContent>
</Card>
)
}
// Default view for other types
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{roundType} Configuration</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Configuration UI for {roundType} rounds is not yet implemented.
</p>
<pre className="mt-4 p-3 bg-muted rounded text-xs overflow-auto">
{JSON.stringify(config, null, 2)}
</pre>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
}
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
}
export function BasicsSection({ state, onChange }: BasicsSectionProps) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Competition Name *</Label>
<Input
id="name"
placeholder="e.g., 2026 Ocean Innovation Challenge"
value={state.name}
onChange={(e) => onChange({ name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug *</Label>
<Input
id="slug"
placeholder="e.g., 2026-ocean-innovation"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
URL-safe identifier (lowercase, hyphens only)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="categoryMode">Category Mode</Label>
<Select value={state.categoryMode} onValueChange={(value) => onChange({ categoryMode: value })}>
<SelectTrigger id="categoryMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">Shared (all categories together)</SelectItem>
<SelectItem value="SEPARATE">Separate (categories evaluated independently)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="startupFinalists">Startup Finalists</Label>
<Input
id="startupFinalists"
type="number"
min={1}
value={state.startupFinalistCount}
onChange={(e) => onChange({ startupFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="conceptFinalists">Concept Finalists</Label>
<Input
id="conceptFinalists"
type="number"
min={1}
value={state.conceptFinalistCount}
onChange={(e) => onChange({ conceptFinalistCount: parseInt(e.target.value, 10) })}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Notifications</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyRoundAdvance">Round Advancement Notifications</Label>
<p className="text-xs text-muted-foreground">
Notify participants when they advance to the next round
</p>
</div>
<Switch
id="notifyRoundAdvance"
checked={state.notifyOnRoundAdvance}
onCheckedChange={(checked) => onChange({ notifyOnRoundAdvance: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyDeadline">Deadline Reminders</Label>
<p className="text-xs text-muted-foreground">
Send reminders as deadlines approach
</p>
</div>
<Switch
id="notifyDeadline"
checked={state.notifyOnDeadlineApproach}
onCheckedChange={(checked) => onChange({ notifyOnDeadlineApproach: checked })}
/>
</div>
{state.notifyOnDeadlineApproach && (
<div className="space-y-2">
<Label>Reminder Days</Label>
<div className="flex flex-wrap gap-2">
{state.deadlineReminderDays.map((days, index) => (
<div key={index} className="flex items-center gap-1">
<Input
type="number"
min={1}
className="w-16"
value={days}
onChange={(e) => {
const newDays = [...state.deadlineReminderDays]
newDays[index] = parseInt(e.target.value, 10)
onChange({ deadlineReminderDays: newDays })
}}
/>
<span className="text-xs text-muted-foreground">days</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Days before deadline to send reminders
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type JuryGroupsSectionProps = {
juryGroups: WizardJuryGroup[]
onChange: (groups: WizardJuryGroup[]) => void
}
export function JuryGroupsSection({ juryGroups, onChange }: JuryGroupsSectionProps) {
const handleAddGroup = () => {
const newGroup: WizardJuryGroup = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
defaultMaxAssignments: 5,
defaultCapMode: 'SOFT',
sortOrder: juryGroups.length,
}
onChange([...juryGroups, newGroup])
}
const handleRemoveGroup = (tempId: string) => {
const updated = juryGroups.filter((g) => g.tempId !== tempId)
const reordered = updated.map((g, index) => ({ ...g, sortOrder: index }))
onChange(reordered)
}
const handleUpdateGroup = (tempId: string, updates: Partial<WizardJuryGroup>) => {
const updated = juryGroups.map((g) =>
g.tempId === tempId ? { ...g, ...updates } : g
)
onChange(updated)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Jury Groups</CardTitle>
<p className="text-sm text-muted-foreground">
Create jury groups for evaluation rounds (optional)
</p>
</CardHeader>
<CardContent className="space-y-3">
{juryGroups.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No jury groups yet. Add groups to assign evaluators to rounds.
</div>
) : (
juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Group Name</Label>
<Input
placeholder="e.g., Technical Jury"
value={group.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateGroup(group.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., technical-jury"
value={group.slug}
onChange={(e) => handleUpdateGroup(group.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Max Assignments per Juror</Label>
<Input
type="number"
min={1}
value={group.defaultMaxAssignments}
onChange={(e) =>
handleUpdateGroup(group.tempId, {
defaultMaxAssignments: parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cap Mode</Label>
<Select
value={group.defaultCapMode}
onValueChange={(value) => handleUpdateGroup(group.tempId, { defaultCapMode: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard (strict limit)</SelectItem>
<SelectItem value="SOFT">Soft (flexible)</SelectItem>
<SelectItem value="NONE">None (unlimited)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveGroup(group.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
<Button variant="outline" className="w-full" onClick={handleAddGroup}>
<Plus className="h-4 w-4 mr-2" />
Add Jury Group
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
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[]
}
type ReviewSectionProps = {
state: WizardState
}
const roundTypeColors: Record<string, string> = {
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 function ReviewSection({ state }: ReviewSectionProps) {
const warnings: string[] = []
if (!state.name) warnings.push('Competition name is required')
if (!state.slug) warnings.push('Competition slug is required')
if (state.rounds.length === 0) warnings.push('At least one round is required')
if (state.rounds.some((r) => !r.name)) warnings.push('All rounds must have a name')
if (state.rounds.some((r) => !r.slug)) warnings.push('All rounds must have a slug')
if (state.juryGroups.some((g) => !g.name)) warnings.push('All jury groups must have a name')
if (state.juryGroups.some((g) => !g.slug)) warnings.push('All jury groups must have a slug')
return (
<div className="space-y-6">
{/* Validation Status */}
{warnings.length > 0 ? (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
Please fix the following issues:
</p>
<ul className="mt-2 space-y-1 text-sm text-destructive/90">
{warnings.map((warning, index) => (
<li key={index} className="ml-4 list-disc">
{warning}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
) : (
<Card className="border-emerald-500/50 bg-emerald-500/5">
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
<p className="text-sm font-medium text-emerald-700">
Ready to create competition
</p>
</div>
</CardContent>
</Card>
)}
{/* Competition Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Name</p>
<p className="text-sm">{state.name || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="text-sm font-mono">{state.slug || <em className="text-muted-foreground">Not set</em>}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Category Mode</p>
<p className="text-sm">{state.categoryMode}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Finalists</p>
<p className="text-sm">
{state.startupFinalistCount} Startup / {state.conceptFinalistCount} Concept
</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Notifications</p>
<div className="flex flex-wrap gap-1.5">
{state.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">
Round Advance
</Badge>
)}
{state.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">
Deadline Reminders ({state.deadlineReminderDays.join(', ')} days)
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
{/* Rounds Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Rounds ({state.rounds.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.rounds.length === 0 ? (
<p className="text-sm text-muted-foreground">No rounds configured</p>
) : (
<div className="space-y-2">
{state.rounds.map((round, index) => (
<div key={round.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{round.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{round.slug || <em>no-slug</em>}</p>
</div>
<Badge
variant="secondary"
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Jury Groups Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Jury Groups ({state.juryGroups.length})
</CardTitle>
</CardHeader>
<CardContent>
{state.juryGroups.length === 0 ? (
<p className="text-sm text-muted-foreground">No jury groups configured</p>
) : (
<div className="space-y-2">
{state.juryGroups.map((group, index) => (
<div key={group.tempId} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{group.name || <em>Unnamed</em>}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{group.slug || <em>no-slug</em>}</p>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<span>Max: {group.defaultMaxAssignments}</span>
<Badge variant="outline" className="text-[10px]">
{group.defaultCapMode}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type RoundsSectionProps = {
rounds: WizardRound[]
onChange: (rounds: WizardRound[]) => void
}
const roundTypes = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-gray-100 text-gray-700' },
{ value: 'FILTERING', label: 'Filtering', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-blue-100 text-blue-700' },
{ value: 'SUBMISSION', label: 'Submission', color: 'bg-purple-100 text-purple-700' },
{ value: 'MENTORING', label: 'Mentoring', color: 'bg-teal-100 text-teal-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-red-100 text-red-700' },
{ value: 'DELIBERATION', label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700' },
]
export function RoundsSection({ rounds, onChange }: RoundsSectionProps) {
const handleAddRound = () => {
const newRound: WizardRound = {
tempId: crypto.randomUUID(),
name: '',
slug: '',
roundType: 'EVALUATION',
sortOrder: rounds.length,
configJson: {},
}
onChange([...rounds, newRound])
}
const handleRemoveRound = (tempId: string) => {
const updated = rounds.filter((r) => r.tempId !== tempId)
// Reorder
const reordered = updated.map((r, index) => ({ ...r, sortOrder: index }))
onChange(reordered)
}
const handleUpdateRound = (tempId: string, updates: Partial<WizardRound>) => {
const updated = rounds.map((r) =>
r.tempId === tempId ? { ...r, ...updates } : r
)
onChange(updated)
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const updated = [...rounds]
;[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
const handleMoveDown = (index: number) => {
if (index === rounds.length - 1) return
const updated = [...rounds]
;[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]
const reordered = updated.map((r, i) => ({ ...r, sortOrder: i }))
onChange(reordered)
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Rounds</CardTitle>
<p className="text-sm text-muted-foreground">
Define the stages of your competition workflow
</p>
</CardHeader>
<CardContent className="space-y-3">
{rounds.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Add your first round to get started.
</div>
) : (
rounds.map((round, index) => {
const typeConfig = roundTypes.find((t) => t.value === round.roundType)
return (
<div key={round.tempId} className="flex items-start gap-2 border rounded-lg p-3">
<div className="flex flex-col gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleMoveDown(index)}
disabled={index === rounds.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Round Name</Label>
<Input
placeholder="e.g., First Evaluation"
value={round.name}
onChange={(e) => {
const name = e.target.value
const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
handleUpdateRound(round.tempId, { name, slug })
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slug</Label>
<Input
placeholder="e.g., first-evaluation"
value={round.slug}
onChange={(e) => handleUpdateRound(round.tempId, { slug: e.target.value })}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs">Round Type</Label>
<Select
value={round.roundType}
onValueChange={(value) => handleUpdateRound(round.tempId, { roundType: value })}
>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roundTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{typeConfig && (
<Badge variant="secondary" className={typeConfig.color}>
{typeConfig.label}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemoveRound(round.tempId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})
)}
<Button variant="outline" className="w-full" onClick={handleAddRound}>
<Plus className="h-4 w-4 mr-2" />
Add Round
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
interface AdminOverrideDialogProps {
sessionId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
projectIds: string[];
}
export function AdminOverrideDialog({
sessionId,
open,
onOpenChange,
projectIds
}: AdminOverrideDialogProps) {
const utils = trpc.useUtils();
const [rankings, setRankings] = useState<Record<string, number>>({});
const [reason, setReason] = useState('');
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ enabled: open }
);
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Admin override applied successfully');
onOpenChange(false);
setRankings({});
setReason('');
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmit = () => {
if (!reason.trim()) {
toast.error('Reason is required for admin override');
return;
}
const rankingsArray = Object.entries(rankings).map(([projectId, rank]) => ({
projectId,
rank
}));
if (rankingsArray.length === 0) {
toast.error('Please assign at least one rank');
return;
}
adminDecideMutation.mutate({
sessionId,
rankings: rankingsArray,
reason: reason.trim()
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Admin Override</DialogTitle>
<DialogDescription>
Manually set the final rankings for this deliberation session. This action will be
audited.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId);
return (
<div key={projectId} className="flex items-center gap-3">
<Input
type="number"
min="1"
placeholder="Rank"
value={rankings[projectId] || ''}
onChange={(e) =>
setRankings({
...rankings,
[projectId]: parseInt(e.target.value) || 0
})
}
className="w-20"
/>
<span className="flex-1 text-sm">{project?.title || projectId}</span>
</div>
);
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Reason (Required) *</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why this admin override is necessary..."
rows={3}
required
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={adminDecideMutation.isPending || !reason.trim()}
>
{adminDecideMutation.isPending ? 'Applying...' : 'Apply Override'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from '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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { AdminOverrideDialog } from './admin-override-dialog';
interface ResultsPanelProps {
sessionId: string;
}
export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
utils.deliberation.aggregate.invalidate();
toast.success('Runoff voting initiated');
},
onError: (err) => {
toast.error(err.message);
}
});
const finalizeMutation = trpc.deliberation.finalize.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Results finalized successfully');
},
onError: (err) => {
toast.error(err.message);
}
});
if (!aggregatedResults) {
return (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No voting results yet</p>
</CardContent>
</Card>
);
}
// Detect ties: check if two or more top-ranked candidates share the same totalScore
const hasTie = (() => {
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
if (!rankings || rankings.length < 2) return false;
// Group projects by totalScore
const scoreGroups = new Map<number, string[]>();
for (const r of rankings) {
const score = r.totalScore ?? 0;
const group = scoreGroups.get(score) || [];
group.push(r.projectId);
scoreGroups.set(score, group);
}
// A tie exists if the highest score is shared by 2+ projects
const topScore = Math.max(...scoreGroups.keys());
const topGroup = scoreGroups.get(topScore);
return (topGroup?.length ?? 0) >= 2;
})();
const tiedProjectIds = hasTie
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
return (
<div className="space-y-4">
{/* Results Table */}
<Card>
<CardHeader>
<CardTitle>Voting Results</CardTitle>
<CardDescription>
Aggregated voting results
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{aggregatedResults.rankings?.map((result: any, index: number) => (
<div
key={result.projectId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
#{index + 1}
</div>
<div>
<p className="font-medium">{result.projectTitle}</p>
<p className="text-sm text-muted-foreground">
{result.votes} votes {result.averageRank?.toFixed(2)} avg rank
</p>
</div>
</div>
<Badge variant="outline" className="text-lg">
{result.totalScore?.toFixed(1) || 0}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
{/* Tie Warning */}
{hasTie && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Tie Detected</AlertTitle>
<AlertDescription className="space-y-3">
<p>
Multiple projects have the same score. You must resolve this before finalizing
results.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
variant="outline"
size="sm"
onClick={() => initRunoffMutation.mutate({ sessionId, tiedProjectIds })}
disabled={initRunoffMutation.isPending}
>
Initiate Runoff Vote
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOverrideDialogOpen(true)}
>
Admin Override
</Button>
</div>
</AlertDescription>
</Alert>
)}
{/* Finalize Button */}
{canFinalize && (
<Card>
<CardContent className="flex items-center justify-between p-6">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium">Ready to Finalize</p>
<p className="text-sm text-muted-foreground">
All votes counted, no ties detected
</p>
</div>
</div>
<Button
onClick={() => finalizeMutation.mutate({ sessionId })}
disabled={finalizeMutation.isPending}
>
{finalizeMutation.isPending ? 'Finalizing...' : 'Finalize Results'}
</Button>
</CardContent>
</Card>
)}
{/* Admin Override Dialog */}
<AdminOverrideDialog
sessionId={sessionId}
open={overrideDialogOpen}
onOpenChange={setOverrideDialogOpen}
projectIds={aggregatedResults.rankings?.map((r: any) => r.projectId) || []}
/>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Bot,
FileText,
RefreshCw,
Loader2,
@@ -38,7 +39,7 @@ import { formatDistanceToNow } from 'date-fns'
interface EvaluationSummaryCardProps {
projectId: string
stageId: string
roundId: string
}
interface ScoringPatterns {
@@ -71,7 +72,7 @@ const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destru
export function EvaluationSummaryCard({
projectId,
stageId,
roundId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
@@ -79,7 +80,7 @@ export function EvaluationSummaryCard({
data: summary,
isLoading,
refetch,
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
const generateMutation = trpc.evaluation.generateSummary.useMutation({
onSuccess: () => {
@@ -95,7 +96,7 @@ export function EvaluationSummaryCard({
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, stageId })
generateMutation.mutate({ projectId, roundId })
}
if (isLoading) {
@@ -157,6 +158,10 @@ export function EvaluationSummaryCard({
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
AI Evaluation Summary
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
<Bot className="h-3 w-3" />
AI Generated
</Badge>
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3" />

View File

@@ -63,7 +63,7 @@ function getMimeLabel(mime: string): string {
}
interface FileRequirementsEditorProps {
stageId: string;
roundId: string;
}
interface RequirementFormData {
@@ -83,35 +83,35 @@ const emptyForm: RequirementFormData = {
};
export function FileRequirementsEditor({
stageId,
roundId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ stageId });
trpc.file.listRequirements.useQuery({ roundId });
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement created");
},
onError: (err) => toast.error(err.message),
});
const updateMutation = trpc.file.updateRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement updated");
},
onError: (err) => toast.error(err.message),
});
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ stageId });
utils.file.listRequirements.invalidate({ roundId });
toast.success("Requirement deleted");
},
onError: (err) => toast.error(err.message),
});
const reorderMutation = trpc.file.reorderRequirements.useMutation({
onSuccess: () => utils.file.listRequirements.invalidate({ stageId }),
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
});
@@ -156,7 +156,7 @@ export function FileRequirementsEditor({
});
} else {
await createMutation.mutateAsync({
stageId,
roundId,
name: form.name.trim(),
description: form.description.trim() || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
@@ -182,7 +182,7 @@ export function FileRequirementsEditor({
newOrder[index],
];
await reorderMutation.mutateAsync({
stageId,
roundId,
orderedIds: newOrder.map((r) => r.id),
});
};

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
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'
interface AddMemberDialogProps {
juryGroupId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const utils = trpc.useUtils()
const { data: userResponse, isLoading: isSearching } = trpc.user.list.useQuery(
{ search: searchQuery, perPage: 20 },
{ enabled: searchQuery.length > 0 }
)
const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
onOpenChange(false)
resetForm()
},
onError: (err) => {
toast.error(err.message)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setRole('MEMBER')
setMaxAssignments('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
toast.error('Please select a user')
return
}
addMember({
juryGroupId,
userId: selectedUserId,
role,
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for a user and assign them to this jury group
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as any)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isPending ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { useState } from 'react'
import { Trash2, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { AddMemberDialog } from './add-member-dialog'
interface JuryMember {
id: string
userId: string
role: string
user: {
id: string
name: string | null
email: string
}
maxAssignmentsOverride: number | null
preferredStartupRatio: number | null
}
interface JuryMembersTableProps {
juryGroupId: string
members: JuryMember[]
}
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const utils = trpc.useUtils()
const { mutate: removeMember, isPending: isRemoving } = trpc.juryGroup.removeMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member removed successfully')
setRemovingMemberId(null)
},
onError: (err) => {
toast.error(err.message)
setRemovingMemberId(null)
},
})
const handleRemove = (memberId: string) => {
removeMember({ id: memberId })
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => setAddDialogOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
) : (
members.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.user.name || 'Unnamed User'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
{member.role}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setRemovingMemberId(member.id)}
disabled={isRemoving}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddMemberDialog
juryGroupId={juryGroupId}
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
<AlertDialog open={!!removingMemberId} onOpenChange={() => setRemovingMemberId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this member from the jury group? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removingMemberId && handleRemove(removingMemberId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from '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 { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
import { toast } from 'sonner';
interface LiveControlPanelProps {
roundId: string;
competitionId: string;
}
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
// TODO: Add getScores to live router
const scores: any[] = [];
// TODO: Implement cursor mutation
const moveCursorMutation = {
mutate: () => {},
isPending: false
};
useEffect(() => {
if (!isTimerRunning) return;
const interval = setInterval(() => {
setTimerSeconds((prev) => {
if (prev <= 1) {
setIsTimerRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isTimerRunning]);
const handlePrevious = () => {
// TODO: Implement previous navigation
toast.info('Previous navigation not yet implemented');
};
const handleNext = () => {
// TODO: Implement next navigation
toast.info('Next navigation not yet implemented');
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-6">
{/* Current Project Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={moveCursorMutation.isPending}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNext}
disabled={moveCursorMutation.isPending}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{cursor?.activeProject ? (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
</div>
<div className="text-sm text-muted-foreground">
Total projects: {cursor.totalProjects}
</div>
</div>
) : (
<p className="text-muted-foreground">No project selected</p>
)}
</CardContent>
</Card>
{/* Timer Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timer
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{!isTimerRunning ? (
<Button
className="flex-1"
onClick={() => setIsTimerRunning(true)}
disabled={timerSeconds === 0}
>
<Play className="mr-2 h-4 w-4" />
Start Timer
</Button>
) : (
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive">
<Square className="mr-2 h-4 w-4" />
Stop Timer
</Button>
)}
<Button
variant="outline"
onClick={() => {
setTimerSeconds(300);
setIsTimerRunning(false);
}}
>
Reset (5:00)
</Button>
</div>
</CardContent>
</Card>
{/* Voting Controls */}
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage jury and audience voting</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full" variant="default">
<Play className="mr-2 h-4 w-4" />
Open Jury Voting
</Button>
<Button className="w-full" variant="outline">
Close Voting
</Button>
</CardContent>
</Card>
{/* Scores Display */}
<Card>
<CardHeader>
<CardTitle>Live Scores</CardTitle>
</CardHeader>
<CardContent>
{scores && scores.length > 0 ? (
<div className="space-y-2">
{scores.map((score: any, index: number) => (
<div
key={score.projectId}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">
#{index + 1} {score.projectTitle}
</p>
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
</div>
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">No scores yet</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
interface Project {
id: string;
title: string;
category: string;
}
interface ProjectNavigatorGridProps {
projects: Project[];
currentProjectId?: string;
onSelect: (id: string) => void;
}
export function ProjectNavigatorGrid({
projects,
currentProjectId,
onSelect
}: ProjectNavigatorGridProps) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{projects.map((project, index) => (
<Card
key={project.id}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
currentProjectId === project.id && 'ring-2 ring-primary'
)}
onClick={() => onSelect(project.id)}
>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-muted-foreground">#{index + 1}</span>
{currentProjectId === project.id && (
<Badge variant="default" className="text-xs">
Current
</Badge>
)}
</div>
<p className="line-clamp-2 text-sm font-medium">{project.title}</p>
<Badge variant="outline" className="text-xs">
{project.category}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -18,15 +18,15 @@ import {
} from '@/lib/pdf-generator'
interface PdfReportProps {
stageId: string
roundId: string
sections: string[]
}
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ stageId, sections },
{ roundId, sections },
{ enabled: false }
)

View File

@@ -1,358 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, 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 { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Save, Loader2 } from 'lucide-react'
type TrackAwardLite = {
id: string
name: string
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null
specialAward: {
id: string
name: string
description: string | null
criteriaText: string | null
useAiEligibility: boolean
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: number | null
votingStartAt: Date | null
votingEndAt: Date | null
status: string
} | null
}
type AwardGovernanceEditorProps = {
pipelineId: string
tracks: TrackAwardLite[]
}
type AwardDraft = {
trackId: string
awardId: string
awardName: string
description: string
criteriaText: string
useAiEligibility: boolean
decisionMode: 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION'
scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED'
maxRankedPicks: string
votingStartAt: string
votingEndAt: string
}
function toDateTimeInputValue(value: Date | null | undefined): string {
if (!value) return ''
const date = new Date(value)
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
function toDateOrUndefined(value: string): Date | undefined {
if (!value) return undefined
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? undefined : parsed
}
export function AwardGovernanceEditor({
pipelineId,
tracks,
}: AwardGovernanceEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, AwardDraft>>({})
const awardTracks = useMemo(
() => tracks.filter((track) => !!track.specialAward),
[tracks]
)
const updateAward = trpc.specialAward.update.useMutation({
onError: (error) => toast.error(error.message),
})
const configureGovernance = trpc.award.configureGovernance.useMutation({
onError: (error) => toast.error(error.message),
})
useEffect(() => {
const nextDrafts: Record<string, AwardDraft> = {}
for (const track of awardTracks) {
const award = track.specialAward
if (!award) continue
nextDrafts[track.id] = {
trackId: track.id,
awardId: award.id,
awardName: award.name,
description: award.description ?? '',
criteriaText: award.criteriaText ?? '',
useAiEligibility: award.useAiEligibility,
decisionMode: track.decisionMode ?? 'JURY_VOTE',
scoringMode: award.scoringMode,
maxRankedPicks: award.maxRankedPicks?.toString() ?? '',
votingStartAt: toDateTimeInputValue(award.votingStartAt),
votingEndAt: toDateTimeInputValue(award.votingEndAt),
}
}
setDrafts(nextDrafts)
}, [awardTracks])
const isSaving = updateAward.isPending || configureGovernance.isPending
const handleSave = async (trackId: string) => {
const draft = drafts[trackId]
if (!draft) return
const votingStartAt = toDateOrUndefined(draft.votingStartAt)
const votingEndAt = toDateOrUndefined(draft.votingEndAt)
if (votingStartAt && votingEndAt && votingEndAt <= votingStartAt) {
toast.error('Voting end must be after voting start')
return
}
const maxRankedPicks = draft.maxRankedPicks
? parseInt(draft.maxRankedPicks, 10)
: undefined
await updateAward.mutateAsync({
id: draft.awardId,
name: draft.awardName.trim(),
description: draft.description.trim() || undefined,
criteriaText: draft.criteriaText.trim() || undefined,
useAiEligibility: draft.useAiEligibility,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await configureGovernance.mutateAsync({
trackId: draft.trackId,
decisionMode: draft.decisionMode,
scoringMode: draft.scoringMode,
maxRankedPicks,
votingStartAt,
votingEndAt,
})
await utils.pipeline.getDraft.invalidate({ id: pipelineId })
toast.success('Award governance updated')
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Award Governance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{awardTracks.length === 0 && (
<p className="text-sm text-muted-foreground">
No award tracks in this pipeline.
</p>
)}
{awardTracks.map((track) => {
const draft = drafts[track.id]
if (!draft) return null
return (
<div key={track.id} className="rounded-md border p-3 space-y-3">
<p className="text-sm font-medium">{track.name}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Award Name</Label>
<Input
value={draft.awardName}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, awardName: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Decision Mode</Label>
<Select
value={draft.decisionMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
decisionMode: value as AwardDraft['decisionMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Scoring Mode</Label>
<Select
value={draft.scoringMode}
onValueChange={(value) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
scoringMode: value as AwardDraft['scoringMode'],
},
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Ranked Picks</Label>
<Input
type="number"
min={1}
max={20}
value={draft.maxRankedPicks}
disabled={draft.scoringMode !== 'RANKED'}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
maxRankedPicks: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-2">
<Switch
checked={draft.useAiEligibility}
onCheckedChange={(checked) =>
setDrafts((prev) => ({
...prev,
[track.id]: {
...draft,
useAiEligibility: checked,
},
}))
}
/>
<Label className="text-xs">AI Eligibility</Label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Voting Start</Label>
<Input
type="datetime-local"
value={draft.votingStartAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingStartAt: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Voting End</Label>
<Input
type="datetime-local"
value={draft.votingEndAt}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, votingEndAt: e.target.value },
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Textarea
rows={2}
value={draft.description}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, description: e.target.value },
}))
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Eligibility Criteria</Label>
<Textarea
rows={3}
value={draft.criteriaText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[track.id]: { ...draft, criteriaText: e.target.value },
}))
}
/>
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={() => handleSave(track.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save Award Settings
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,379 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, 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 {
Plus,
Save,
Trash2,
ArrowUp,
ArrowDown,
Loader2,
Play,
} from 'lucide-react'
type FilteringRulesEditorProps = {
stageId: string
}
type RuleDraft = {
id: string
name: string
ruleType: 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
priority: number
configText: string
}
const DEFAULT_CONFIG_BY_TYPE: Record<
RuleDraft['ruleType'],
Record<string, unknown>
> = {
FIELD_BASED: {
conditions: [
{
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
],
logic: 'AND',
action: 'PASS',
},
DOCUMENT_CHECK: {
requiredFileTypes: ['application/pdf'],
minFileCount: 1,
action: 'REJECT',
},
AI_SCREENING: {
criteriaText:
'Project must clearly demonstrate ocean impact and practical feasibility.',
action: 'FLAG',
},
}
export function FilteringRulesEditor({ stageId }: FilteringRulesEditorProps) {
const utils = trpc.useUtils()
const [drafts, setDrafts] = useState<Record<string, RuleDraft>>({})
const { data: rules = [], isLoading } = trpc.filtering.getRules.useQuery({
stageId,
})
const createRule = trpc.filtering.createRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule created')
},
onError: (error) => toast.error(error.message),
})
const updateRule = trpc.filtering.updateRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule updated')
},
onError: (error) => toast.error(error.message),
})
const deleteRule = trpc.filtering.deleteRule.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
toast.success('Filtering rule deleted')
},
onError: (error) => toast.error(error.message),
})
const reorderRules = trpc.filtering.reorderRules.useMutation({
onSuccess: async () => {
await utils.filtering.getRules.invalidate({ stageId })
},
onError: (error) => toast.error(error.message),
})
const executeRules = trpc.filtering.executeRules.useMutation({
onSuccess: (data) => {
toast.success(
`Filtering executed: ${data.passed} passed, ${data.filteredOut} filtered, ${data.flagged} flagged`
)
},
onError: (error) => toast.error(error.message),
})
const orderedRules = useMemo(
() => [...rules].sort((a, b) => a.priority - b.priority),
[rules]
)
useEffect(() => {
const nextDrafts: Record<string, RuleDraft> = {}
for (const rule of orderedRules) {
nextDrafts[rule.id] = {
id: rule.id,
name: rule.name,
ruleType: rule.ruleType,
priority: rule.priority,
configText: JSON.stringify(rule.configJson ?? {}, null, 2),
}
}
setDrafts(nextDrafts)
}, [orderedRules])
const handleCreateRule = async () => {
const priority = orderedRules.length
await createRule.mutateAsync({
stageId,
name: `Rule ${priority + 1}`,
ruleType: 'FIELD_BASED',
priority,
configJson: DEFAULT_CONFIG_BY_TYPE.FIELD_BASED,
})
}
const handleSaveRule = async (ruleId: string) => {
const draft = drafts[ruleId]
if (!draft) return
let parsedConfig: Record<string, unknown>
try {
parsedConfig = JSON.parse(draft.configText) as Record<string, unknown>
} catch {
toast.error('Rule config must be valid JSON')
return
}
await updateRule.mutateAsync({
id: ruleId,
name: draft.name.trim(),
ruleType: draft.ruleType,
priority: draft.priority,
configJson: parsedConfig,
})
}
const handleMoveRule = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= orderedRules.length) return
const reordered = [...orderedRules]
const temp = reordered[index]
reordered[index] = reordered[targetIndex]
reordered[targetIndex] = temp
await reorderRules.mutateAsync({
rules: reordered.map((rule, idx) => ({
id: rule.id,
priority: idx,
})),
})
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">Filtering Rules</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Loading rules...
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">Filtering Rules</CardTitle>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => executeRules.mutate({ stageId })}
disabled={executeRules.isPending}
>
{executeRules.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1.5 h-3.5 w-3.5" />
)}
Run
</Button>
<Button
type="button"
size="sm"
onClick={handleCreateRule}
disabled={createRule.isPending}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Rule
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{orderedRules.length === 0 && (
<p className="text-sm text-muted-foreground">
No filtering rules configured yet.
</p>
)}
{orderedRules.map((rule, index) => {
const draft = drafts[rule.id]
if (!draft) return null
return (
<div key={rule.id} className="rounded-md border p-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-12">
<div className="sm:col-span-5 space-y-1">
<Label className="text-xs">Name</Label>
<Input
value={draft.name}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
name: e.target.value,
},
}))
}
/>
</div>
<div className="sm:col-span-4 space-y-1">
<Label className="text-xs">Rule Type</Label>
<Select
value={draft.ruleType}
onValueChange={(value) => {
const ruleType = value as RuleDraft['ruleType']
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
ruleType,
configText: JSON.stringify(
DEFAULT_CONFIG_BY_TYPE[ruleType],
null,
2
),
},
}))
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">Document Check</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 space-y-1">
<Label className="text-xs">Priority</Label>
<Input
type="number"
value={draft.priority}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
priority: parseInt(e.target.value, 10) || 0,
},
}))
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Rule Config (JSON)</Label>
<Textarea
className="font-mono text-xs min-h-28"
value={draft.configText}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[rule.id]: {
...draft,
configText: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'up')}
disabled={index === 0 || reorderRules.isPending}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleMoveRule(index, 'down')}
disabled={
index === orderedRules.length - 1 || reorderRules.isPending
}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSaveRule(rule.id)}
disabled={updateRule.isPending}
>
{updateRule.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteRule.mutate({ id: rule.id })}
disabled={deleteRule.isPending}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -1,276 +0,0 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
type StageNode = {
id: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type FlowchartTrack = {
id: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineFlowchartProps = {
tracks: FlowchartTrack[]
selectedStageId?: string | null
onStageSelect?: (stageId: string) => void
className?: string
compact?: boolean
}
const stageTypeColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
INTAKE: { bg: '#eff6ff', border: '#93c5fd', text: '#1d4ed8', glow: '#3b82f6' },
FILTER: { bg: '#fffbeb', border: '#fcd34d', text: '#b45309', glow: '#f59e0b' },
EVALUATION: { bg: '#faf5ff', border: '#c084fc', text: '#7e22ce', glow: '#a855f7' },
SELECTION: { bg: '#fff1f2', border: '#fda4af', text: '#be123c', glow: '#f43f5e' },
LIVE_FINAL: { bg: '#ecfdf5', border: '#6ee7b7', text: '#047857', glow: '#10b981' },
RESULTS: { bg: '#ecfeff', border: '#67e8f9', text: '#0e7490', glow: '#06b6d4' },
}
const NODE_WIDTH = 140
const NODE_HEIGHT = 70
const NODE_GAP = 32
const ARROW_SIZE = 6
const TRACK_LABEL_HEIGHT = 28
const TRACK_GAP = 20
export function PipelineFlowchart({
tracks,
selectedStageId,
onStageSelect,
className,
compact = false,
}: PipelineFlowchartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [hoveredStageId, setHoveredStageId] = useState<string | null>(null)
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
// Calculate dimensions
const nodeW = compact ? 100 : NODE_WIDTH
const nodeH = compact ? 50 : NODE_HEIGHT
const gap = compact ? 20 : NODE_GAP
const maxStages = Math.max(...sortedTracks.map((t) => t.stages.length), 1)
const totalWidth = maxStages * nodeW + (maxStages - 1) * gap + 40
const totalHeight =
sortedTracks.length * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) - TRACK_GAP + 20
const getNodePosition = useCallback(
(trackIndex: number, stageIndex: number) => {
const x = 20 + stageIndex * (nodeW + gap)
const y = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP) + TRACK_LABEL_HEIGHT
return { x, y }
},
[nodeW, nodeH, gap]
)
return (
<div
ref={containerRef}
className={cn('relative rounded-lg border bg-card', className)}
>
<div className="overflow-x-auto">
<svg
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
className="min-w-full"
>
<defs>
<marker
id="arrowhead"
markerWidth={ARROW_SIZE}
markerHeight={ARROW_SIZE}
refX={ARROW_SIZE}
refY={ARROW_SIZE / 2}
orient="auto"
>
<path
d={`M 0 0 L ${ARROW_SIZE} ${ARROW_SIZE / 2} L 0 ${ARROW_SIZE} Z`}
fill="#94a3b8"
/>
</marker>
{/* Glow filter for selected node */}
<filter id="selectedGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3b82f6" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{sortedTracks.map((track, trackIndex) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
const trackY = 10 + trackIndex * (nodeH + TRACK_LABEL_HEIGHT + TRACK_GAP)
return (
<g key={track.id}>
{/* Track label */}
<text
x={20}
y={trackY + 14}
className="fill-muted-foreground text-[11px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{track.name}
{track.kind !== 'MAIN' && ` (${track.kind})`}
</text>
{/* Arrows between stages */}
{sortedStages.map((stage, stageIndex) => {
if (stageIndex === 0) return null
const from = getNodePosition(trackIndex, stageIndex - 1)
const to = getNodePosition(trackIndex, stageIndex)
const arrowY = from.y + nodeH / 2
return (
<line
key={`arrow-${stage.id}`}
x1={from.x + nodeW}
y1={arrowY}
x2={to.x - 2}
y2={arrowY}
stroke="#94a3b8"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
/>
)
})}
{/* Stage nodes */}
{sortedStages.map((stage, stageIndex) => {
const pos = getNodePosition(trackIndex, stageIndex)
const isSelected = selectedStageId === stage.id
const isHovered = hoveredStageId === stage.id
const colors = stageTypeColors[stage.stageType] ?? {
bg: '#f8fafc',
border: '#cbd5e1',
text: '#475569',
glow: '#64748b',
}
const projectCount = stage._count?.projectStageStates ?? 0
return (
<g
key={stage.id}
onClick={() => onStageSelect?.(stage.id)}
onMouseEnter={() => setHoveredStageId(stage.id)}
onMouseLeave={() => setHoveredStageId(null)}
className={cn(onStageSelect && 'cursor-pointer')}
filter={isSelected ? 'url(#selectedGlow)' : undefined}
>
{/* Selection ring */}
{isSelected && (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
x={pos.x - 3}
y={pos.y - 3}
width={nodeW + 6}
height={nodeH + 6}
rx={10}
fill="none"
stroke={colors.glow}
strokeWidth={2}
strokeDasharray="none"
/>
)}
{/* Node background */}
<rect
x={pos.x}
y={pos.y}
width={nodeW}
height={nodeH}
rx={8}
fill={colors.bg}
stroke={isSelected ? colors.glow : colors.border}
strokeWidth={isSelected ? 2 : 1}
style={{
transition: 'stroke 0.15s, stroke-width 0.15s',
transform: isHovered && !isSelected ? 'scale(1.02)' : undefined,
transformOrigin: `${pos.x + nodeW / 2}px ${pos.y + nodeH / 2}px`,
}}
/>
{/* Stage name */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 20 : 24)}
textAnchor="middle"
fill={colors.text}
className={cn(compact ? 'text-[10px]' : 'text-xs', 'font-medium')}
style={{ fontFamily: 'inherit' }}
>
{stage.name.length > (compact ? 12 : 16)
? stage.name.slice(0, compact ? 10 : 14) + '...'
: stage.name}
</text>
{/* Type badge */}
<text
x={pos.x + nodeW / 2}
y={pos.y + (compact ? 34 : 40)}
textAnchor="middle"
fill={colors.text}
className="text-[9px]"
style={{ fontFamily: 'inherit', opacity: 0.7 }}
>
{stage.stageType.replace('_', ' ')}
</text>
{/* Project count */}
{!compact && projectCount > 0 && (
<>
<rect
x={pos.x + nodeW / 2 - 14}
y={pos.y + nodeH - 18}
width={28}
height={14}
rx={7}
fill={colors.border}
opacity={0.3}
/>
<text
x={pos.x + nodeW / 2}
y={pos.y + nodeH - 8}
textAnchor="middle"
fill={colors.text}
className="text-[9px] font-medium"
style={{ fontFamily: 'inherit' }}
>
{projectCount}
</text>
</>
)}
</g>
)
})}
</g>
)
})}
</svg>
</div>
{/* Scroll hint gradient for mobile */}
{totalWidth > 400 && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-card to-transparent pointer-events-none sm:hidden" />
)}
</div>
)
}

View File

@@ -1,121 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { ArrowRight } from 'lucide-react'
type StageNode = {
id?: string
name: string
stageType: string
sortOrder: number
_count?: { projectStageStates: number }
}
type TrackLane = {
id?: string
name: string
kind: string
sortOrder: number
stages: StageNode[]
}
type PipelineVisualizationProps = {
tracks: TrackLane[]
className?: string
}
const stageColors: Record<string, string> = {
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
}
const trackKindBadge: Record<string, string> = {
MAIN: 'bg-blue-100 text-blue-700',
AWARD: 'bg-amber-100 text-amber-700',
SHOWCASE: 'bg-purple-100 text-purple-700',
}
export function PipelineVisualization({
tracks,
className,
}: PipelineVisualizationProps) {
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div className={cn('space-y-4', className)}>
{sortedTracks.map((track) => {
const sortedStages = [...track.stages].sort(
(a, b) => a.sortOrder - b.sortOrder
)
return (
<Card key={track.id ?? track.name} className="p-4">
{/* Track header */}
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-semibold">{track.name}</span>
<Badge
variant="secondary"
className={cn(
'text-[10px] h-5',
trackKindBadge[track.kind] ?? ''
)}
>
{track.kind}
</Badge>
</div>
{/* Stage flow */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{sortedStages.map((stage, index) => (
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
<div
className={cn(
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
)}
>
<span className="text-xs font-medium text-center leading-tight">
{stage.name}
</span>
<span className="text-[10px] opacity-70 mt-0.5">
{stage.stageType.replace('_', ' ')}
</span>
{stage._count?.projectStageStates !== undefined &&
stage._count.projectStageStates > 0 && (
<Badge
variant="secondary"
className="text-[9px] h-4 px-1 mt-1"
>
{stage._count.projectStageStates}
</Badge>
)}
</div>
{index < sortedStages.length - 1 && (
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
</div>
))}
{sortedStages.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No stages
</span>
)}
</div>
</Card>
)
})}
{tracks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No tracks to visualize
</p>
)}
</div>
)
}

View File

@@ -1,450 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Plus, X, Sparkles, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
// ─── Field & Operator Definitions ────────────────────────────────────────────
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' },
{ value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' },
{ value: 'country', label: 'Country', tooltip: 'Country of origin' },
{ value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' },
{ value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' },
] as const
const OPERATOR_OPTIONS = [
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'does not equal' },
{ value: 'in', label: 'is one of' },
{ value: 'contains', label: 'contains' },
{ value: 'gt', label: 'greater than' },
{ value: 'lt', label: 'less than' },
] as const
// ─── Types ───────────────────────────────────────────────────────────────────
type SimpleCondition = {
field: string
operator: string
value: unknown
}
type CompoundPredicate = {
logic: 'and' | 'or'
conditions: SimpleCondition[]
}
type PredicateBuilderProps = {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
pipelineId?: string
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isSimpleCondition(obj: Record<string, unknown>): obj is SimpleCondition {
return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj
}
function isCompoundPredicate(obj: Record<string, unknown>): obj is CompoundPredicate {
return 'logic' in obj && Array.isArray((obj as CompoundPredicate).conditions)
}
function detectInitialMode(value: Record<string, unknown>): 'simple' | 'ai' | 'advanced' {
if (isCompoundPredicate(value)) return 'simple'
if (isSimpleCondition(value)) return 'simple'
// Empty object or unknown shape
if (Object.keys(value).length === 0) return 'simple'
return 'advanced'
}
function valueToConditions(value: Record<string, unknown>): SimpleCondition[] {
if (isCompoundPredicate(value)) {
return value.conditions.map((c) => ({
field: c.field || 'competitionCategory',
operator: c.operator || 'eq',
value: c.value ?? '',
}))
}
if (isSimpleCondition(value)) {
return [{ field: value.field, operator: value.operator, value: value.value }]
}
return [{ field: 'competitionCategory', operator: 'eq', value: '' }]
}
function valueToLogic(value: Record<string, unknown>): 'and' | 'or' {
if (isCompoundPredicate(value)) return value.logic
return 'and'
}
function conditionsToPredicate(
conditions: SimpleCondition[],
logic: 'and' | 'or'
): Record<string, unknown> {
if (conditions.length === 1) {
return conditions[0] as unknown as Record<string, unknown>
}
return { logic, conditions }
}
function displayValue(val: unknown): string {
if (Array.isArray(val)) return val.join(', ')
if (typeof val === 'boolean') return val ? 'true' : 'false'
return String(val ?? '')
}
function parseInputValue(text: string, field: string): unknown {
if (field === 'wantsMentorship') {
return text.toLowerCase() === 'true'
}
if (text.includes(',')) {
return text.split(',').map((s) => s.trim()).filter(Boolean)
}
return text
}
// ─── Simple Mode ─────────────────────────────────────────────────────────────
function SimpleMode({
value,
onChange,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}) {
const [conditions, setConditions] = useState<SimpleCondition[]>(() => valueToConditions(value))
const [logic, setLogic] = useState<'and' | 'or'>(() => valueToLogic(value))
const emitChange = useCallback(
(nextConditions: SimpleCondition[], nextLogic: 'and' | 'or') => {
onChange(conditionsToPredicate(nextConditions, nextLogic))
},
[onChange]
)
const updateCondition = (index: number, field: keyof SimpleCondition, val: unknown) => {
const next = conditions.map((c, i) => (i === index ? { ...c, [field]: val } : c))
setConditions(next)
emitChange(next, logic)
}
const addCondition = () => {
const next = [...conditions, { field: 'competitionCategory', operator: 'eq', value: '' }]
setConditions(next)
emitChange(next, logic)
}
const removeCondition = (index: number) => {
if (conditions.length <= 1) return
const next = conditions.filter((_, i) => i !== index)
setConditions(next)
emitChange(next, logic)
}
const toggleLogic = () => {
const nextLogic = logic === 'and' ? 'or' : 'and'
setLogic(nextLogic)
emitChange(conditions, nextLogic)
}
return (
<TooltipProvider delayDuration={300}>
<div className="space-y-2">
{conditions.map((condition, index) => (
<div key={index}>
{index > 0 && (
<div className="flex items-center gap-2 py-1">
<div className="h-px flex-1 bg-border" />
<Button
type="button"
variant="outline"
size="sm"
className="h-5 px-2 text-[10px] font-medium"
onClick={toggleLogic}
>
{logic.toUpperCase()}
</Button>
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-[160px] shrink-0">
<Select
value={condition.field}
onValueChange={(v) => updateCondition(index, 'field', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{FIELD_OPTIONS.find((f) => f.value === condition.field)?.tooltip || 'Select a field'}
</TooltipContent>
</Tooltip>
<div className="w-[130px] shrink-0">
<Select
value={condition.operator}
onValueChange={(v) => updateCondition(index, 'operator', v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
className="h-8 text-xs flex-1 min-w-[100px]"
value={displayValue(condition.value)}
onChange={(e) =>
updateCondition(index, 'value', parseInputValue(e.target.value, condition.field))
}
placeholder={condition.field === 'wantsMentorship' ? 'true / false' : 'e.g. STARTUP'}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeCondition(index)}
disabled={conditions.length <= 1}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={addCondition}
>
<Plus className="mr-1 h-3 w-3" />
Add condition
</Button>
</div>
</TooltipProvider>
)
}
// ─── AI Mode ─────────────────────────────────────────────────────────────────
function AIMode({
value,
onChange,
pipelineId,
onSwitchToSimple,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
pipelineId?: string
onSwitchToSimple: () => void
}) {
const [text, setText] = useState('')
const [result, setResult] = useState<{
predicateJson: Record<string, unknown>
explanation: string
} | null>(null)
const handleGenerate = () => {
toast.error('AI rule parsing is not currently available')
}
const handleApply = () => {
if (result) {
onChange(result.predicateJson)
toast.success('Rule applied')
}
}
return (
<div className="space-y-3">
<div className="space-y-2">
<Textarea
className="text-xs min-h-16"
placeholder='Describe your rule in plain English, e.g. "Route startup projects from France to the Fast Track"'
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button
type="button"
size="sm"
onClick={handleGenerate}
disabled={!text.trim() || !pipelineId}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Generate Rule
</Button>
</div>
{!pipelineId && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5" />
Save the pipeline first to enable AI rule generation.
</div>
)}
{result && (
<div className="rounded-md border bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium">Generated Rule</div>
<p className="text-xs text-muted-foreground">{result.explanation}</p>
<pre className="text-[10px] font-mono bg-background rounded p-2 overflow-x-auto">
{JSON.stringify(result.predicateJson, null, 2)}
</pre>
<div className="flex items-center gap-2">
<Button type="button" size="sm" className="h-7 text-xs" onClick={handleApply}>
Apply Rule
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
onChange(result.predicateJson)
onSwitchToSimple()
}}
>
Edit in Simple mode
</Button>
</div>
</div>
)}
{Object.keys(value).length > 0 && !result && (
<div className="text-[10px] text-muted-foreground">
Current predicate: <code className="font-mono">{JSON.stringify(value)}</code>
</div>
)}
</div>
)
}
// ─── Advanced Mode ───────────────────────────────────────────────────────────
function AdvancedMode({
value,
onChange,
}: {
value: Record<string, unknown>
onChange: (predicate: Record<string, unknown>) => void
}) {
const [jsonText, setJsonText] = useState(() => JSON.stringify(value, null, 2))
const [error, setError] = useState<string | null>(null)
const handleChange = (text: string) => {
setJsonText(text)
try {
const parsed = JSON.parse(text) as Record<string, unknown>
setError(null)
onChange(parsed)
} catch (e) {
setError(e instanceof Error ? e.message : 'Invalid JSON')
}
}
return (
<div className="space-y-2">
<Textarea
className="font-mono text-xs min-h-28"
value={jsonText}
onChange={(e) => handleChange(e.target.value)}
placeholder='{ "field": "competitionCategory", "operator": "eq", "value": "STARTUP" }'
/>
{error && (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
<p className="text-[10px] text-muted-foreground">
Use <code className="font-mono">{'{ field, operator, value }'}</code> for simple conditions
or <code className="font-mono">{'{ logic: "and"|"or", conditions: [...] }'}</code> for compound rules.
</p>
</div>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export function PredicateBuilder({ value, onChange, pipelineId }: PredicateBuilderProps) {
const [activeTab, setActiveTab] = useState<string>(() => detectInitialMode(value))
return (
<div className="space-y-2">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="h-8">
<TabsTrigger value="simple" className="text-xs px-3 h-6">
Simple
</TabsTrigger>
<TabsTrigger value="ai" className="text-xs px-3 h-6">
<Sparkles className="mr-1 h-3 w-3" />
AI
</TabsTrigger>
<TabsTrigger value="advanced" className="text-xs px-3 h-6">
Advanced
</TabsTrigger>
</TabsList>
<TabsContent value="simple">
<SimpleMode value={value} onChange={onChange} />
</TabsContent>
<TabsContent value="ai">
<AIMode
value={value}
onChange={onChange}
pipelineId={pipelineId}
onSwitchToSimple={() => setActiveTab('simple')}
/>
</TabsContent>
<TabsContent value="advanced">
<AdvancedMode value={value} onChange={onChange} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -1,243 +0,0 @@
'use client'
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 { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard'
const ASSIGNMENT_CATEGORIES = [
{ key: 'STARTUP', label: 'Startups' },
{ key: 'BUSINESS_CONCEPT', label: 'Business Concepts' },
] as const
type AssignmentSectionProps = {
config: EvaluationConfig
onChange: (config: EvaluationConfig) => void
isActive?: boolean
}
export function AssignmentSection({ config, onChange, isActive }: AssignmentSectionProps) {
const updateConfig = (updates: Partial<EvaluationConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Required Reviews per Project</Label>
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
</div>
<Input
type="number"
min={1}
max={20}
value={config.requiredReviews ?? 3}
disabled={isActive}
onChange={(e) =>
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
}
/>
<p className="text-xs text-muted-foreground">
Minimum number of jury evaluations per project
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Max Load per Juror</Label>
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
</div>
<Input
type="number"
min={1}
max={100}
value={config.maxLoadPerJuror ?? 20}
disabled={isActive}
onChange={(e) =>
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
}
/>
<p className="text-xs text-muted-foreground">
Maximum projects assigned to one juror
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Min Load per Juror</Label>
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
</div>
<Input
type="number"
min={0}
max={50}
value={config.minLoadPerJuror ?? 5}
disabled={isActive}
onChange={(e) =>
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
}
/>
<p className="text-xs text-muted-foreground">
Target minimum projects per juror
</p>
</div>
</div>
{(config.minLoadPerJuror ?? 0) > (config.maxLoadPerJuror ?? 20) && (
<p className="text-sm text-destructive">
Min load per juror cannot exceed max load per juror.
</p>
)}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Availability Weighting</Label>
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
</div>
<p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects
</p>
</div>
<Switch
checked={config.availabilityWeighting ?? true}
onCheckedChange={(checked) =>
updateConfig({ availabilityWeighting: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Overflow Policy</Label>
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
</div>
<Select
value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) =>
updateConfig({
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="queue">
Queue Hold unassigned projects for manual assignment
</SelectItem>
<SelectItem value="expand_pool">
Expand Pool Invite additional jurors automatically
</SelectItem>
<SelectItem value="reduce_reviews">
Reduce Reviews Lower required reviews to fit available jurors
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Category Balance */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Balance assignments by category</Label>
<InfoTooltip content="Ensure each juror receives a balanced mix of project categories within their assignment limits." />
</div>
<p className="text-xs text-muted-foreground">
Set per-category min/max assignment targets per juror
</p>
</div>
<Switch
checked={config.categoryQuotasEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({
categoryQuotasEnabled: checked,
categoryQuotas: checked
? config.categoryQuotas ?? {
STARTUP: { min: 0, max: 10 },
BUSINESS_CONCEPT: { min: 0, max: 10 },
}
: config.categoryQuotas,
})
}
disabled={isActive}
/>
</div>
{config.categoryQuotasEnabled && (
<div className="space-y-4 rounded-md border p-4">
{ASSIGNMENT_CATEGORIES.map((cat) => {
const catQuota = (config.categoryQuotas ?? {})[cat.key] ?? { min: 0, max: 10 }
return (
<div key={cat.key} className="space-y-2">
<Label className="text-sm font-medium">{cat.label}</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Min per juror</Label>
<Input
type="number"
min={0}
max={50}
value={catQuota.min}
disabled={isActive}
onChange={(e) =>
updateConfig({
categoryQuotas: {
...config.categoryQuotas,
[cat.key]: {
...catQuota,
min: parseInt(e.target.value, 10) || 0,
},
},
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Max per juror</Label>
<Input
type="number"
min={0}
max={100}
value={catQuota.max}
disabled={isActive}
onChange={(e) =>
updateConfig({
categoryQuotas: {
...config.categoryQuotas,
[cat.key]: {
...catQuota,
max: parseInt(e.target.value, 10) || 0,
},
},
})
}
/>
</div>
</div>
{catQuota.min > catQuota.max && (
<p className="text-xs text-destructive">
Min cannot exceed max for {cat.label.toLowerCase()}.
</p>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,254 +0,0 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
type AwardsSectionProps = {
tracks: WizardTrackConfig[]
onChange: (tracks: WizardTrackConfig[]) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps) {
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
const addAward = () => {
const newTrack = defaultAwardTrack(awardTracks.length)
newTrack.sortOrder = tracks.length
onChange([...tracks, newTrack])
}
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
const updated = [...tracks]
const awardIndex = tracks.findIndex(
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
)
if (awardIndex >= 0) {
updated[awardIndex] = { ...updated[awardIndex], ...updates }
onChange(updated)
}
}
const removeAward = (index: number) => {
const toRemove = awardTracks[index]
onChange(tracks.filter((t) => t !== toRemove))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Configure special award tracks that run alongside the main competition.
</p>
<Button type="button" variant="outline" size="sm" onClick={addAward} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Award Track
</Button>
</div>
{awardTracks.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
No award tracks configured. Awards are optional.
</div>
)}
{awardTracks.map((track, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
Award Track {index + 1}
</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the &quot;{track.name}&quot; award track and all
its stages. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAward(index)}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Award Name</Label>
<Input
placeholder="e.g., Innovation Award"
value={track.awardConfig?.name ?? track.name}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateAward(index, {
name,
slug: slugify(name),
awardConfig: {
...track.awardConfig,
name,
},
})
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Shared: projects compete in the main track and this award simultaneously. Exclusive: projects are routed exclusively to this track." />
</div>
<Select
value={track.routingModeDefault ?? 'SHARED'}
onValueChange={(value) =>
updateAward(index, {
routingModeDefault: value as RoutingMode,
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">
Shared Projects compete in main + this award
</SelectItem>
<SelectItem value="EXCLUSIVE">
Exclusive Projects enter only this track
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Decision Mode</Label>
<InfoTooltip content="How the winner is determined for this award track." />
</div>
<Select
value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) =>
updateAward(index, { decisionMode: value as DecisionMode })
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
<SelectItem value="AWARD_MASTER_DECISION">
Award Master Decision
</SelectItem>
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Scoring Mode</Label>
<InfoTooltip content="The method used to aggregate scores for this award." />
</div>
<Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
scoringMode: value as AwardScoringMode,
},
})
}
disabled={isActive}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
placeholder="Brief description of this award..."
value={track.awardConfig?.description ?? ''}
rows={2}
className="text-sm"
onChange={(e) =>
updateAward(index, {
awardConfig: {
...track.awardConfig!,
description: e.target.value,
},
})
}
/>
</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,99 +0,0 @@
'use client'
import { useEffect } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard'
type BasicsSectionProps = {
state: WizardState
onChange: (updates: Partial<WizardState>) => void
isActive?: boolean
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
const { data: programs, isLoading } = trpc.program.list.useQuery({})
// Auto-generate slug from name
useEffect(() => {
if (state.name && !state.slug) {
onChange({ slug: slugify(state.name) })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="pipeline-name">Pipeline Name</Label>
<Input
id="pipeline-name"
placeholder="e.g., MOPC 2026"
value={state.name}
onChange={(e) => {
const name = e.target.value
onChange({ name, slug: slugify(name) })
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-slug">Slug</Label>
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
</div>
<Input
id="pipeline-slug"
placeholder="e.g., mopc-2026"
value={state.slug}
onChange={(e) => onChange({ slug: e.target.value })}
pattern="^[a-z0-9-]+$"
disabled={isActive}
/>
<p className="text-xs text-muted-foreground">
{isActive
? 'Slug cannot be changed on active pipelines'
: 'Lowercase letters, numbers, and hyphens only'}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-program">Program</Label>
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
</div>
<Select
value={state.programId}
onValueChange={(value) => onChange({ programId: value })}
>
<SelectTrigger id="pipeline-program">
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.year})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,479 +0,0 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible'
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
type KnownField = {
value: string
label: string
operators: string[]
valueType: 'select' | 'text' | 'number' | 'boolean'
placeholder?: string
}
const KNOWN_FIELDS: KnownField[] = [
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
]
const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
is_one_of: 'is one of',
after: 'after',
before: 'before',
exists: 'exists',
min_length: 'min length',
greaterThan: 'greater than',
lessThan: 'less than',
equals: 'equals',
}
// ─── Human-readable preview for a rule ───────────────────────────────────────
function getRulePreview(rule: FilterRuleConfig): string {
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
const fieldLabel = field?.label ?? rule.field
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
if (rule.operator === 'exists') {
return `Projects where ${fieldLabel} exists will pass`
}
const valueStr = typeof rule.value === 'boolean'
? (rule.value ? 'Yes' : 'No')
: String(rule.value)
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
}
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
const AI_VISIBLE_FIELDS = [
'Project title',
'Description',
'Competition category',
'Ocean issue',
'Country & region',
'Tags',
'Founded year',
'Team size',
'File count',
]
// ─── Props ───────────────────────────────────────────────────────────────────
type FilteringSectionProps = {
config: FilterConfig
onChange: (config: FilterConfig) => void
isActive?: boolean
}
// ─── Component ───────────────────────────────────────────────────────────────
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const [rulesOpen, setRulesOpen] = useState(false)
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates })
}
const rules = config.rules ?? []
const aiCriteriaText = config.aiCriteriaText ?? ''
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...rules]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, rules: updated })
}
const addRule = () => {
onChange({
...config,
rules: [
...rules,
{ field: '', operator: 'is', value: '', weight: 1 },
],
})
}
const removeRule = (index: number) => {
onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
}
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
}
const highPct = Math.round(thresholds.high * 100)
const medPct = Math.round(thresholds.medium * 100)
return (
<div className="space-y-6">
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label>
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Use AI to evaluate projects against your screening criteria
</p>
</div>
<Switch
checked={config.aiRubricEnabled}
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
disabled={isActive}
/>
</div>
{config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2">
<Label className="text-sm font-medium">Screening Criteria</Label>
<p className="text-xs text-muted-foreground">
Describe what makes a project eligible or ineligible in natural language.
The AI will evaluate each project against these criteria.
</p>
<Textarea
value={aiCriteriaText}
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
rows={5}
className="resize-y"
disabled={isActive}
/>
{aiCriteriaText.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
{aiCriteriaText.length} characters
</p>
)}
</div>
{/* "What the AI sees" Info Card */}
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
<Info className="h-3.5 w-3.5" />
<span>What the AI sees</span>
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-muted/50 border-muted">
<CardContent className="pt-3 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-2">
All data is anonymized before being sent to the AI. Only these fields are included:
</p>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{AI_VISIBLE_FIELDS.map((field) => (
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
{field}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground/70 mt-2 italic">
No personal identifiers (names, emails, etc.) are sent to the AI.
</p>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
{/* Confidence Thresholds */}
<div className="space-y-3">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Control how the AI's confidence score maps to outcomes.
</p>
{/* Visual range preview */}
<div className="flex items-center gap-1 text-[10px] font-medium">
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
Auto-approve above {highPct}%
</div>
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
Review {medPct}%{'\u2013'}{highPct}%
</div>
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
Auto-reject below {medPct}%
</div>
</div>
{/* High threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
<Label className="text-xs">Auto-approve threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{highPct}%</span>
</div>
<Slider
value={[highPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
high: v / 100,
},
})
}
min={50}
max={100}
step={5}
disabled={isActive}
/>
</div>
{/* Medium threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
<Label className="text-xs">Manual review threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{medPct}%</span>
</div>
<Slider
value={[medPct]}
onValueChange={([v]) =>
updateConfig({
aiConfidenceThresholds: {
...thresholds,
medium: v / 100,
},
})
}
min={20}
max={80}
step={5}
disabled={isActive}
/>
</div>
</div>
</div>
)}
</div>
{/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Manual Review Queue</Label>
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
</div>
<p className="text-xs text-muted-foreground">
Projects below medium confidence go to manual review
</p>
</div>
<Switch
checked={config.manualQueueEnabled}
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
disabled={isActive}
/>
</div>
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Shield className="h-4 w-4 text-muted-foreground" />
<Label className="cursor-pointer">Eligibility Rules</Label>
<span className="text-xs text-muted-foreground">
({rules.length} rule{rules.length !== 1 ? 's' : ''})
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
{rulesOpen && (
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
Deterministic rules that projects must pass. Applied before AI screening.
</p>
<CollapsibleContent>
<div className="space-y-3 mt-3">
{rules.map((rule, index) => {
const fieldConfig = getFieldConfig(rule.field)
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
return (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4 space-y-2">
{/* Rule inputs */}
<div className="flex items-start gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
{/* Field dropdown */}
<Select
value={rule.field}
onValueChange={(value) => {
const newFieldConfig = getFieldConfig(value)
const firstOp = newFieldConfig?.operators[0] ?? 'is'
updateRule(index, {
field: value,
operator: firstOp,
value: newFieldConfig?.valueType === 'boolean' ? true : '',
})
}}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{KNOWN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator dropdown (filtered by field) */}
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive || !rule.field}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op} value={op}>
{OPERATOR_LABELS[op] ?? op}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (adapted by field type) */}
{rule.operator === 'exists' ? (
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
(no value needed)
</div>
) : fieldConfig?.valueType === 'boolean' ? (
<Select
value={String(rule.value)}
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
) : fieldConfig?.valueType === 'number' ? (
<Input
type="number"
placeholder={fieldConfig.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
/>
) : (
<Input
placeholder={fieldConfig?.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive mt-0.5"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Human-readable preview */}
{rule.field && rule.operator && (
<p className="text-xs text-muted-foreground italic pl-1">
{getRulePreview(rule)}
</p>
)}
</CardContent>
</Card>
)
})}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
</p>
)}
{!rulesOpen ? null : rules.length > 0 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
}

View File

@@ -1,289 +0,0 @@
'use client'
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 { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
import {
FILE_TYPE_CATEGORIES,
getActiveCategoriesFromMimeTypes,
categoriesToMimeTypes,
} from '@/lib/file-type-categories'
type FileTypePickerProps = {
value: string[]
onChange: (mimeTypes: string[]) => void
}
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
const activeCategories = getActiveCategoriesFromMimeTypes(value)
const toggleCategory = (categoryId: string) => {
const isActive = activeCategories.includes(categoryId)
const newCategories = isActive
? activeCategories.filter((id) => id !== categoryId)
: [...activeCategories, categoryId]
onChange(categoriesToMimeTypes(newCategories))
}
return (
<div className="space-y-2">
<Label className="text-xs">Accepted Types</Label>
<div className="flex flex-wrap gap-1.5">
{FILE_TYPE_CATEGORIES.map((cat) => {
const isActive = activeCategories.includes(cat.id)
return (
<Button
key={cat.id}
type="button"
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => toggleCategory(cat.id)}
>
{cat.label}
</Button>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{activeCategories.length === 0 ? (
<Badge variant="secondary" className="text-[10px]">All types</Badge>
) : (
activeCategories.map((catId) => {
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
return cat ? (
<Badge key={catId} variant="secondary" className="text-[10px]">
{cat.label}
</Badge>
) : null
})
)}
</div>
</div>
)
}
type IntakeSectionProps = {
config: IntakeConfig
onChange: (config: IntakeConfig) => void
isActive?: boolean
}
export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps) {
const updateConfig = (updates: Partial<IntakeConfig>) => {
onChange({ ...config, ...updates })
}
const fileRequirements = config.fileRequirements ?? []
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
const updated = [...fileRequirements]
updated[index] = { ...updated[index], ...updates }
onChange({ ...config, fileRequirements: updated })
}
const addFileReq = () => {
onChange({
...config,
fileRequirements: [
...fileRequirements,
{
name: '',
description: '',
acceptedMimeTypes: ['application/pdf'],
maxSizeMB: 50,
isRequired: false,
},
],
})
}
const removeFileReq = (index: number) => {
const updated = fileRequirements.filter((_, i) => i !== index)
onChange({ ...config, fileRequirements: updated })
}
return (
<div className="space-y-6">
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Some settings are locked because this pipeline is active.
</p>
)}
{/* Submission Window */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Submission Window</Label>
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
</div>
<p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake
</p>
</div>
<Switch
checked={config.submissionWindowEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ submissionWindowEnabled: checked })
}
disabled={isActive}
/>
</div>
</div>
{/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Late Submission Policy</Label>
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
</div>
<Select
value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) =>
updateConfig({
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reject">Reject late submissions</SelectItem>
<SelectItem value="flag">Accept but flag as late</SelectItem>
<SelectItem value="accept">Accept normally</SelectItem>
</SelectContent>
</Select>
</div>
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Grace Period (hours)</Label>
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
</div>
<Input
type="number"
min={0}
max={168}
value={config.lateGraceHours ?? 24}
onChange={(e) =>
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
}
/>
</div>
)}
</div>
{/* File Requirements */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label>File Requirements</Label>
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
</div>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement
</Button>
</div>
{fileRequirements.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No file requirements configured. Projects can be submitted without files.
</p>
)}
{fileRequirements.map((req, index) => (
<Card key={index}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-start gap-3">
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
<div className="flex-1 grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">File Name</Label>
<Input
placeholder="e.g., Executive Summary"
value={req.name}
onChange={(e) => updateFileReq(index, { name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
max={500}
value={req.maxSizeMB ?? ''}
onChange={(e) =>
updateFileReq(index, {
maxSizeMB: parseInt(e.target.value) || undefined,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description</Label>
<Input
placeholder="Brief description of this requirement"
value={req.description ?? ''}
onChange={(e) =>
updateFileReq(index, { description: e.target.value })
}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={req.isRequired}
onCheckedChange={(checked) =>
updateFileReq(index, { isRequired: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
</div>
<div className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes ?? []}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeFileReq(index)}
disabled={isActive}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,158 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = {
config: LiveFinalConfig
onChange: (config: LiveFinalConfig) => void
isActive?: boolean
}
export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSectionProps) {
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Jury Voting</Label>
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
</div>
<p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event
</p>
</div>
<Switch
checked={config.juryVotingEnabled ?? true}
onCheckedChange={(checked) =>
updateConfig({ juryVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Audience Voting</Label>
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
</div>
<p className="text-xs text-muted-foreground">
Allow audience members to vote on projects
</p>
</div>
<Switch
checked={config.audienceVotingEnabled ?? false}
onCheckedChange={(checked) =>
updateConfig({ audienceVotingEnabled: checked })
}
disabled={isActive}
/>
</div>
{(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Audience Vote Weight</Label>
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
</div>
<div className="flex items-center gap-3">
<Slider
value={[(config.audienceVoteWeight ?? 0) * 100]}
onValueChange={([v]) =>
updateConfig({ audienceVoteWeight: v / 100 })
}
min={0}
max={100}
step={5}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">
{Math.round((config.audienceVoteWeight ?? 0) * 100)}%
</span>
</div>
<p className="text-xs text-muted-foreground">
Percentage weight of audience votes in the final score
</p>
</div>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Cohort Setup Mode</Label>
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
</div>
<Select
value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
Manual Admin creates cohorts and assigns projects
</SelectItem>
<SelectItem value="auto">
Auto System creates cohorts from pipeline results
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Result Reveal Policy</Label>
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
</div>
<Select
value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) =>
updateConfig({
revealPolicy: value as LiveFinalConfig['revealPolicy'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Immediate Results shown after each vote
</SelectItem>
<SelectItem value="delayed">
Delayed Results hidden until admin reveals
</SelectItem>
<SelectItem value="ceremony">
Ceremony Results revealed in dramatic sequence
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}

View File

@@ -1,228 +0,0 @@
'use client'
import { useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import {
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client'
type MainTrackSectionProps = {
stages: WizardStageConfig[]
onChange: (stages: WizardStageConfig[]) => void
isActive?: boolean
}
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
]
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectionProps) {
const updateStage = useCallback(
(index: number, updates: Partial<WizardStageConfig>) => {
const updated = [...stages]
updated[index] = { ...updated[index], ...updates }
onChange(updated)
},
[stages, onChange]
)
const addStage = () => {
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
onChange([
...stages,
{
name: '',
slug: '',
stageType: 'EVALUATION',
sortOrder: maxOrder + 1,
configJson: {},
},
])
}
const removeStage = (index: number) => {
if (stages.length <= 2) return // Minimum 2 stages
const updated = stages.filter((_, i) => i !== index)
// Re-number sortOrder
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
const moveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= stages.length) return
const updated = [...stages]
const temp = updated[index]
updated[index] = updated[newIndex]
updated[newIndex] = temp
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5 mb-1">
<p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required.
</p>
<InfoTooltip
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
side="right"
/>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Stage
</Button>
</div>
{isActive && (
<p className="text-sm text-amber-600 bg-amber-50 rounded-md px-3 py-2">
Stage structure is locked because this pipeline is active. Use the Advanced editor for config changes.
</p>
)}
<div className="space-y-2">
{stages.map((stage, index) => {
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
const hasDuplicateSlug = stage.slug && stages.some((s, i) => i !== index && s.slug === stage.slug)
return (
<Card key={index}>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3">
{/* Reorder */}
<div className="flex flex-col shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={isActive || index === 0}
onClick={() => moveStage(index, 'up')}
>
<ChevronUp className="h-3 w-3" />
</Button>
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
disabled={isActive || index === stages.length - 1}
onClick={() => moveStage(index, 'down')}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Order number */}
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
{index + 1}
</span>
{/* Stage name */}
<div className="flex-1 min-w-0">
<Input
placeholder="Stage name"
value={stage.name}
className={cn('h-8 text-sm', hasDuplicateSlug && 'border-destructive')}
disabled={isActive}
onChange={(e) => {
const name = e.target.value
updateStage(index, { name, slug: slugify(name) })
}}
/>
{hasDuplicateSlug && (
<p className="text-[10px] text-destructive mt-0.5">Duplicate name</p>
)}
</div>
{/* Stage type */}
<div className="w-36 shrink-0">
<Select
value={stage.stageType}
onValueChange={(value) =>
updateStage(index, { stageType: value as StageType })
}
disabled={isActive}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STAGE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type badge */}
<Badge
variant="secondary"
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
>
{typeInfo?.label}
</Badge>
{/* Remove */}
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={isActive || stages.length <= 2}
onClick={() => removeStage(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
{stages.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
No stages configured. Click &quot;Add Stage&quot; to begin.
</div>
)}
</div>
)
}

View File

@@ -1,161 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = {
config: Record<string, boolean>
onChange: (config: Record<string, boolean>) => void
overridePolicy: Record<string, unknown>
onOverridePolicyChange: (policy: Record<string, unknown>) => void
isActive?: boolean
}
const NOTIFICATION_EVENTS = [
{
key: 'stage.transitioned',
label: 'Stage Transitioned',
description: 'When a stage changes status (draft → active → closed)',
},
{
key: 'filtering.completed',
label: 'Filtering Completed',
description: 'When batch filtering finishes processing',
},
{
key: 'assignment.generated',
label: 'Assignments Generated',
description: 'When jury assignments are created or updated',
},
{
key: 'live.cursor.updated',
label: 'Live Cursor Updated',
description: 'When the live presentation moves to next project',
},
{
key: 'cohort.window.changed',
label: 'Cohort Window Changed',
description: 'When a cohort voting window opens or closes',
},
{
key: 'decision.overridden',
label: 'Decision Overridden',
description: 'When an admin overrides an automated decision',
},
{
key: 'award.winner.finalized',
label: 'Award Winner Finalized',
description: 'When a special award winner is selected',
},
]
export function NotificationsSection({
config,
onChange,
overridePolicy,
onOverridePolicyChange,
isActive,
}: NotificationsSectionProps) {
const toggleEvent = (key: string, enabled: boolean) => {
onChange({ ...config, [key]: enabled })
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default.
</p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</div>
<div className="space-y-2">
{NOTIFICATION_EVENTS.map((event) => (
<Card key={event.key}>
<CardContent className="py-3 px-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Label className="text-sm font-medium">{event.label}</Label>
<p className="text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
<Switch
checked={config[event.key] !== false}
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
disabled={isActive}
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* Override Governance */}
<div className="space-y-3 pt-2 border-t">
<Label>Override Governance</Label>
<p className="text-xs text-muted-foreground">
Who can override automated decisions in this pipeline?
</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
}
disabled
/>
<Label className="text-sm">Super Admins (always enabled)</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('PROGRAM_ADMIN')) {
roles.push('PROGRAM_ADMIN')
} else if (!checked) {
const idx = roles.indexOf('PROGRAM_ADMIN')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Program Admins</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={
Array.isArray(overridePolicy.allowedRoles) &&
overridePolicy.allowedRoles.includes('AWARD_MASTER')
}
onCheckedChange={(checked) => {
const roles = Array.isArray(overridePolicy.allowedRoles)
? [...overridePolicy.allowedRoles]
: ['SUPER_ADMIN']
if (checked && !roles.includes('AWARD_MASTER')) {
roles.push('AWARD_MASTER')
} else if (!checked) {
const idx = roles.indexOf('AWARD_MASTER')
if (idx >= 0) roles.splice(idx, 1)
}
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
}}
/>
<Label className="text-sm">Award Masters</Label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,93 +0,0 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ResultsConfig } from '@/types/pipeline-wizard'
type ResultsSectionProps = {
config: ResultsConfig
onChange: (config: ResultsConfig) => void
isActive?: boolean
}
export function ResultsSection({
config,
onChange,
isActive,
}: ResultsSectionProps) {
const updateConfig = (updates: Partial<ResultsConfig>) => {
onChange({ ...config, ...updates })
}
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Publication Mode</Label>
<InfoTooltip content="Manual publish requires explicit admin action. Auto publish triggers on stage close." />
</div>
<Select
value={config.publicationMode ?? 'manual'}
onValueChange={(value) =>
updateConfig({
publicationMode: value as ResultsConfig['publicationMode'],
})
}
disabled={isActive}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_on_close">Auto on Close</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Detailed Scores</Label>
<InfoTooltip content="Expose detailed score breakdowns in published results." />
</div>
<p className="text-xs text-muted-foreground">
Controls score transparency in the results experience
</p>
</div>
<Switch
checked={config.showDetailedScores ?? false}
onCheckedChange={(checked) => updateConfig({ showDetailedScores: checked })}
disabled={isActive}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label>Show Rankings</Label>
<InfoTooltip content="Display ordered rankings in final results." />
</div>
<p className="text-xs text-muted-foreground">
Disable to show winners only without full ranking table
</p>
</div>
<Switch
checked={config.showRankings ?? true}
onCheckedChange={(checked) => updateConfig({ showRankings: checked })}
disabled={isActive}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,314 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight, ShieldCheck } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation'
import { normalizeStageConfig } from '@/lib/stage-config-schema'
import type { WizardState, ValidationResult, WizardStageConfig } from '@/types/pipeline-wizard'
type ReviewSectionProps = {
state: WizardState
}
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
if (result.valid && result.warnings.length === 0) {
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
}
if (result.valid && result.warnings.length > 0) {
return <AlertTriangle className="h-4 w-4 text-amber-500" />
}
return <AlertCircle className="h-4 w-4 text-destructive" />
}
function ValidationSection({
label,
result,
}: {
label: string
result: ValidationResult
}) {
return (
<div className="flex items-start gap-3 py-2">
<ValidationStatusIcon result={result} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{label}</p>
{result.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive mt-0.5">
{err}
</p>
))}
{result.warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-600 mt-0.5">
{warn}
</p>
))}
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
)}
</div>
</div>
)
}
function stagePolicySummary(stage: WizardStageConfig): string {
const config = normalizeStageConfig(
stage.stageType,
stage.configJson as Record<string, unknown>
)
switch (stage.stageType) {
case 'INTAKE':
return `${String(config.lateSubmissionPolicy)} late policy, ${Array.isArray(config.fileRequirements) ? config.fileRequirements.length : 0} file reqs`
case 'FILTER':
return `${Array.isArray(config.rules) ? config.rules.length : 0} rules, AI ${config.aiRubricEnabled ? 'on' : 'off'}`
case 'EVALUATION':
return `${String(config.requiredReviews)} reviews, load ${String(config.minLoadPerJuror)}-${String(config.maxLoadPerJuror)}`
case 'SELECTION':
return `ranking ${String(config.rankingMethod)}, tie ${String(config.tieBreaker)}`
case 'LIVE_FINAL':
return `jury ${config.juryVotingEnabled ? 'on' : 'off'}, audience ${config.audienceVotingEnabled ? 'on' : 'off'}`
case 'RESULTS':
return `publication ${String(config.publicationMode)}, rankings ${config.showRankings ? 'shown' : 'hidden'}`
default:
return 'Configured'
}
}
export function ReviewSection({ state }: ReviewSectionProps) {
const validation = validateAll(state)
const totalTracks = state.tracks.length
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
const totalTransitions = state.tracks.reduce(
(sum, t) => sum + Math.max(0, t.stages.length - 1),
0
)
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
const blockers = [
...validation.sections.basics.errors,
...validation.sections.tracks.errors,
...validation.sections.notifications.errors,
]
const warnings = [
...validation.sections.basics.warnings,
...validation.sections.tracks.warnings,
...validation.sections.notifications.warnings,
]
const hasMainTrack = state.tracks.some((track) => track.kind === 'MAIN')
const hasStages = totalStages > 0
const hasNotificationDefaults = enabledNotifications > 0
const publishReady = validation.valid && hasMainTrack && hasStages
return (
<div className="space-y-6">
<div
className={cn(
'rounded-lg border p-4',
publishReady
? 'border-emerald-200 bg-emerald-50'
: 'border-destructive/30 bg-destructive/5'
)}
>
<div className="flex items-start gap-2">
{publishReady ? (
<CheckCircle2 className="h-5 w-5 text-emerald-600 mt-0.5" />
) : (
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
)}
<div>
<p className={cn('font-medium', publishReady ? 'text-emerald-800' : 'text-destructive')}>
{publishReady
? 'Pipeline is ready for publish'
: 'Pipeline has publish blockers'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Draft save can proceed with warnings. Publish should only proceed with zero blockers.
</p>
</div>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Readiness Checks</CardTitle>
<InfoTooltip content="Critical blockers prevent publish. Warnings indicate recommended fixes." />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{blockers.length}</p>
<p className="text-xs text-muted-foreground">Blockers</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{warnings.length}</p>
<p className="text-xs text-muted-foreground">Warnings</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalTracks}</p>
<p className="text-xs text-muted-foreground">Tracks</p>
</div>
<div className="rounded-md border p-2 text-center">
<p className="text-xl font-semibold">{totalStages}</p>
<p className="text-xs text-muted-foreground">Stages</p>
</div>
</div>
{blockers.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p className="text-xs font-medium text-destructive mb-1">Publish Blockers</p>
{blockers.map((blocker, i) => (
<p key={i} className="text-xs text-destructive">
{blocker}
</p>
))}
</div>
)}
{warnings.length > 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-700 mb-1">Warnings</p>
{warnings.map((warn, i) => (
<p key={i} className="text-xs text-amber-700">
{warn}
</p>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Detail</CardTitle>
<InfoTooltip content="Automated checks per setup section." />
</div>
</CardHeader>
<CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} />
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
<ValidationSection label="Notifications" result={validation.sections.notifications} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Structure and Policy Matrix</CardTitle>
<InfoTooltip content="Stage-by-stage policy preview used for final sanity check before creation." />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold">{totalTracks}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<Layers className="h-3 w-3" />
Tracks
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalStages}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<GitBranch className="h-3 w-3" />
Stages
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{totalTransitions}</p>
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<ArrowRight className="h-3 w-3" />
Transitions
</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{enabledNotifications}</p>
<p className="text-xs text-muted-foreground">Notifications</p>
</div>
</div>
<div className="space-y-3">
{state.tracks.map((track, i) => (
<div key={i} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
track.kind === 'MAIN'
? 'bg-blue-100 text-blue-700'
: track.kind === 'AWARD'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-700'
)}
>
{track.kind}
</Badge>
<span className="text-sm font-medium">{track.name || '(unnamed track)'}</span>
</div>
<span className="text-xs text-muted-foreground">{track.stages.length} stages</span>
</div>
<div className="space-y-1">
{track.stages.map((stage, stageIndex) => (
<div
key={stageIndex}
className="flex items-center justify-between text-xs border-b last:border-0 py-1.5"
>
<span className="font-medium">
{stageIndex + 1}. {stage.name || '(unnamed stage)'} ({stage.stageType})
</span>
<span className="text-muted-foreground">{stagePolicySummary(stage)}</span>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
Publish Guardrails
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between rounded-md border p-2">
<span>Main track present</span>
<Badge variant={hasMainTrack ? 'default' : 'destructive'}>
{hasMainTrack ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>At least one stage configured</span>
<Badge variant={hasStages ? 'default' : 'destructive'}>
{hasStages ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Validation blockers cleared</span>
<Badge variant={blockers.length === 0 ? 'default' : 'destructive'}>
{blockers.length === 0 ? 'Pass' : 'Fail'}
</Badge>
</div>
<div className="flex items-center justify-between rounded-md border p-2">
<span>Notification policy configured</span>
<Badge variant={hasNotificationDefaults ? 'default' : 'secondary'}>
{hasNotificationDefaults ? 'Configured' : 'Optional'}
</Badge>
</div>
</CardContent>
</Card>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More