Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
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:
153
src/app/(jury)/jury/competitions/[roundId]/live/page.tsx
Normal file
153
src/app/(jury)/jury/competitions/[roundId]/live/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/app/(jury)/jury/competitions/[roundId]/page.tsx
Normal file
118
src/app/(jury)/jury/competitions/[roundId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
116
src/app/(jury)/jury/competitions/page.tsx
Normal file
116
src/app/(jury)/jury/competitions/page.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user