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