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

@@ -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>
)
}