Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -1,480 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
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,
FileText,
ExternalLink,
AlertCircle,
} from 'lucide-react'
import { Progress } from '@/components/ui/progress'
import { cn, formatDate, truncate } from '@/lib/utils'
function getCriteriaProgress(evaluation: {
criterionScoresJson: unknown
form: { criteriaJson: unknown }
} | null): { completed: number; total: number } | null {
if (!evaluation || !evaluation.form?.criteriaJson) return null
const criteria = evaluation.form.criteriaJson as Array<{ id: string; type?: string }>
// Only count scoreable criteria (exclude section_header)
const scoreable = criteria.filter((c) => c.type !== 'section_header')
const total = scoreable.length
if (total === 0) return null
const scores = (evaluation.criterionScoresJson || {}) as Record<string, unknown>
const completed = scoreable.filter((c) => scores[c.id] != null && scores[c.id] !== '').length
return { completed, total }
}
function getDeadlineUrgency(deadline: Date | null): { label: string; className: string } | null {
if (!deadline) return null
const now = new Date()
const diff = deadline.getTime() - now.getTime()
const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (daysLeft < 0) return { label: 'Overdue', className: 'text-muted-foreground' }
if (daysLeft <= 2) return { label: `${daysLeft}d left`, className: 'text-red-600 font-semibold' }
if (daysLeft <= 7) return { label: `${daysLeft}d left`, className: 'text-amber-600 font-medium' }
return { label: `${daysLeft}d left`, className: 'text-muted-foreground' }
}
async function AssignmentsContent({
roundId,
}: {
roundId?: string
}) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
// Get assignments, optionally filtered by round
const assignments = await prisma.assignment.findMany({
where: {
userId,
...(roundId ? { roundId } : {}),
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
files: {
select: {
id: true,
fileType: true,
},
},
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
year: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
updatedAt: true,
criterionScoresJson: true,
form: {
select: {
criteriaJson: true,
},
},
},
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' },
],
})
if (assignments.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments found</p>
<p className="text-sm text-muted-foreground">
{roundId
? 'No projects assigned to you for this round'
: "You don't have any project assignments yet"}
</p>
</CardContent>
</Card>
)
}
const now = new Date()
const completedCount = assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length
const inProgressCount = assignments.filter(a => a.evaluation?.status === 'DRAFT').length
const pendingCount = assignments.filter(a => !a.evaluation).length
const overallProgress = assignments.length > 0 ? Math.round((completedCount / assignments.length) * 100) : 0
return (
<div className="space-y-6">
{/* Progress Summary */}
<Card className="bg-muted/30">
<CardContent className="py-4">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{completedCount}</p>
<p className="text-xs text-muted-foreground">Completed</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
<div>
<p className="text-2xl font-bold">{inProgressCount}</p>
<p className="text-xs text-muted-foreground">In Progress</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
<div className="ml-auto">
<Progress value={overallProgress} className="h-2 w-32" gradient />
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
</div>
</div>
</CardContent>
</Card>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Deadline</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<TableRow key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm">
<TableCell>
<Link
href={`/jury/projects/${assignment.project.id}`}
className="block group"
>
<p className="font-medium group-hover:text-primary group-hover:underline transition-colors">
{truncate(assignment.project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
</Link>
</TableCell>
<TableCell>
<div>
<p>{assignment.round.name}</p>
<p className="text-sm text-muted-foreground">
{assignment.round.program.year} Edition
</p>
</div>
</TableCell>
<TableCell>
{assignment.round.votingEndAt ? (
<div>
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
) : (
<span className="text-muted-foreground">No deadline</span>
)}
</TableCell>
<TableCell>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
) : isDraft ? (
<div className="space-y-1.5">
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
{(() => {
const progress = getCriteriaProgress(assignment.evaluation)
if (!progress) return null
const pct = Math.round((progress.completed / progress.total) * 100)
return (
<div className="flex items-center gap-2">
<Progress value={pct} className="h-1.5 w-16" />
<span className="text-xs text-muted-foreground">{progress.completed}/{progress.total}</span>
</div>
)
})()}
</div>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isCompleted ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${assignment.project.id}`}>
View
</Link>
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<Card key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Link
href={`/jury/projects/${assignment.project.id}`}
className="space-y-1 group"
>
<CardTitle className="text-base group-hover:text-primary group-hover:underline transition-colors">
{assignment.project.title}
</CardTitle>
<CardDescription>
{assignment.project.teamName}
</CardDescription>
</Link>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Done
</Badge>
) : isDraft ? (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
Draft
</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{assignment.round.name}</span>
</div>
{assignment.round.votingEndAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<div className="text-right">
<span>{formatDate(assignment.round.votingEndAt)}</span>
{(() => {
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
if (!urgency || isCompleted) return null
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
})()}
</div>
</div>
)}
{isDraft && (() => {
const progress = getCriteriaProgress(assignment.evaluation)
if (!progress) return null
const pct = Math.round((progress.completed / progress.total) * 100)
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="text-xs">{progress.completed}/{progress.total} criteria</span>
</div>
<Progress value={pct} className="h-1.5" />
</div>
)
})()}
<div className="pt-2">
{isCompleted ? (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View Evaluation
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" className="w-full" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link href={`/jury/projects/${assignment.project.id}`}>
View Project
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function AssignmentsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default async function JuryAssignmentsPage({
searchParams,
}: {
searchParams: Promise<{ round?: string }>
}) {
const params = await searchParams
const roundId = params.round
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Assignments</h1>
<p className="text-muted-foreground">
Projects assigned to you for evaluation
</p>
</div>
{/* Content */}
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentsContent roundId={roundId} />
</Suspense>
</div>
)
}

View File

@@ -1,719 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertCircle,
ArrowLeft,
GitCompare,
MapPin,
Users,
FileText,
CheckCircle2,
Clock,
XCircle,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Criterion = {
id: string
label: string
description?: string
scale?: string
weight?: number
type?: string
}
type ComparisonItem = {
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
assignmentId: string
}
type ComparisonData = {
items: ComparisonItem[]
criteria: Criterion[] | null
scales: Record<string, { min: number; max: number }> | null
}
function getScoreColor(score: number, max: number): string {
const ratio = score / max
if (ratio >= 0.8) return 'bg-green-500'
if (ratio >= 0.6) return 'bg-emerald-400'
if (ratio >= 0.4) return 'bg-amber-400'
if (ratio >= 0.2) return 'bg-orange-400'
return 'bg-red-400'
}
function getScoreTextColor(score: number, max: number): string {
const ratio = score / max
if (ratio >= 0.8) return 'text-green-600 dark:text-green-400'
if (ratio >= 0.6) return 'text-emerald-600 dark:text-emerald-400'
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400'
return 'text-red-600 dark:text-red-400'
}
function ScoreRing({ score, max }: { score: number; max: number }) {
const pct = Math.round((score / max) * 100)
const circumference = 2 * Math.PI * 36
const offset = circumference - (pct / 100) * circumference
return (
<div className="relative inline-flex items-center justify-center">
<svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
<circle
cx="40" cy="40" r="36"
className="stroke-muted"
strokeWidth="6" fill="none"
/>
<circle
cx="40" cy="40" r="36"
className={cn(
'transition-all duration-500',
pct >= 80 ? 'stroke-green-500' :
pct >= 60 ? 'stroke-emerald-400' :
pct >= 40 ? 'stroke-amber-400' : 'stroke-red-400'
)}
strokeWidth="6" fill="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-lg font-bold tabular-nums">{score}</span>
<span className="text-[10px] text-muted-foreground">/{max}</span>
</div>
</div>
)
}
function ScoreBar({ score, max, isHighest }: { score: number; max: number; isHighest: boolean }) {
const pct = Math.round((score / max) * 100)
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', getScoreColor(score, max))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn(
'text-sm font-medium tabular-nums w-8 text-right',
isHighest && 'font-bold',
getScoreTextColor(score, max)
)}>
{score}
</span>
</div>
)
}
export default function CompareProjectsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [comparing, setComparing] = useState(false)
const { data: assignments, isLoading: loadingAssignments } =
trpc.assignment.myAssignments.useQuery({})
const rounds = useMemo(() => {
if (!assignments) return []
const roundMap = new Map<string, { id: string; name: string }>()
for (const a of assignments as Array<{ round: { id: string; name: string } }>) {
if (a.round && !roundMap.has(a.round.id)) {
roundMap.set(a.round.id, { id: a.round.id, name: String(a.round.name) })
}
}
return Array.from(roundMap.values())
}, [assignments])
const activeRoundId = selectedRoundId || (rounds.length > 0 ? rounds[0].id : '')
const roundProjects = useMemo(() => {
if (!assignments || !activeRoundId) return []
return (assignments as Array<{
project: Record<string, unknown>
round: { id: string; name: string }
evaluation?: Record<string, unknown>
}>)
.filter((a) => a.round.id === activeRoundId)
.map((a) => ({
...a.project,
roundName: a.round.name,
evaluation: a.evaluation,
}))
}, [assignments, activeRoundId])
const { data: comparisonData, isLoading: loadingComparison } =
trpc.evaluation.getMultipleForComparison.useQuery(
{ projectIds: selectedIds, roundId: activeRoundId },
{ enabled: comparing && selectedIds.length >= 2 && !!activeRoundId }
)
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
if (prev.includes(projectId)) {
return prev.filter((id) => id !== projectId)
}
if (prev.length >= 3) return prev
return [...prev, projectId]
})
setComparing(false)
}
const handleCompare = () => {
if (selectedIds.length >= 2) setComparing(true)
}
const handleReset = () => {
setComparing(false)
setSelectedIds([])
}
const handleRoundChange = (roundId: string) => {
setSelectedRoundId(roundId)
setSelectedIds([])
setComparing(false)
}
if (loadingAssignments) return <CompareSkeleton />
const data = comparisonData as ComparisonData | undefined
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Compare Projects</h1>
<p className="text-muted-foreground">
Select 2-3 projects from the same round to compare side by side
</p>
</div>
<div className="flex items-center gap-2">
{comparing && (
<Button variant="outline" onClick={handleReset}>
Reset
</Button>
)}
{!comparing && (
<Button onClick={handleCompare} disabled={selectedIds.length < 2}>
<GitCompare className="mr-2 h-4 w-4" />
Compare ({selectedIds.length})
</Button>
)}
</div>
</div>
{/* Round selector */}
{rounds.length > 1 && !comparing && (
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">Round:</span>
<Select value={activeRoundId} onValueChange={handleRoundChange}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Project selector */}
{!comparing && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roundProjects.map((project: Record<string, unknown>) => {
const projectId = project.id as string
const isSelected = selectedIds.includes(projectId)
const isDisabled = !isSelected && selectedIds.length >= 3
return (
<Card
key={projectId}
className={cn(
'cursor-pointer transition-all',
isSelected
? 'border-primary ring-2 ring-primary/20'
: isDisabled
? 'opacity-50'
: 'hover:border-primary/50'
)}
onClick={() => !isDisabled && toggleProject(projectId)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleProject(projectId)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{String(project.title || 'Untitled')}
</p>
<p className="text-sm text-muted-foreground truncate">
{String(project.teamName || '')}
</p>
</div>
{project.evaluation ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{roundProjects.length === 0 && !loadingAssignments && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<GitCompare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects assigned</p>
<p className="text-sm text-muted-foreground">
You need at least 2 assigned projects to use the comparison feature.
</p>
</CardContent>
</Card>
)}
{/* Comparison view */}
{comparing && loadingComparison && <CompareSkeleton />}
{comparing && data && (
<div className="space-y-6">
{/* Project summary cards */}
<div
className={cn(
'grid gap-4 grid-cols-1',
data.items.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{data.items.map((item) => (
<ComparisonCard
key={String(item.project.id)}
project={item.project}
evaluation={item.evaluation}
criteria={data.criteria}
scales={data.scales}
/>
))}
</div>
{/* Side-by-side criterion comparison table */}
{data.criteria && data.criteria.filter((c) => c.type !== 'section_header').length > 0 && (
<CriterionComparisonTable
items={data.items}
criteria={data.criteria}
scales={data.scales}
/>
)}
{/* Divergence Summary */}
{data.criteria && (() => {
const scCriteria = data.criteria.filter((c) => c.type !== 'section_header')
const getMaxForCriterion = (criterion: Criterion) => {
if (criterion.scale && data.scales && data.scales[criterion.scale]) return data.scales[criterion.scale].max
return 10
}
const getScoreForItem = (item: ComparisonItem, criterionId: string): number | null => {
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
if (!scores) return null
const val = scores[criterionId]
if (val == null) return null
const num = Number(val)
return isNaN(num) ? null : num
}
const divergentCount = scCriteria.filter(criterion => {
const scores = data.items.map(item => getScoreForItem(item, criterion.id)).filter((s): s is number => s !== null)
if (scores.length < 2) return false
const max = Math.max(...scores)
const min = Math.min(...scores)
const range = getMaxForCriterion(criterion)
return range > 0 && (max - min) / range >= 0.4
}).length
if (divergentCount === 0) return null
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-amber-600" />
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
{divergentCount} criterion{divergentCount > 1 ? 'a' : ''} with significant score divergence ({'>'}40% range difference)
</p>
</div>
</div>
)
})()}
</div>
)}
</div>
)
}
function ComparisonCard({
project,
evaluation,
criteria,
scales,
}: {
project: Record<string, unknown>
evaluation: Record<string, unknown> | null
criteria: Criterion[] | null
scales: Record<string, { min: number; max: number }> | null
}) {
const tags = Array.isArray(project.tags) ? project.tags : []
const files = Array.isArray(project.files) ? project.files : []
const scores = (evaluation?.criterionScoresJson || evaluation?.scores) as Record<string, unknown> | undefined
const globalScore = evaluation?.globalScore as number | null | undefined
const binaryDecision = evaluation?.binaryDecision as boolean | null | undefined
// Build a criterion label lookup
const criterionLabels = useMemo(() => {
const map: Record<string, { label: string; scale?: string }> = {}
if (criteria) {
for (const c of criteria) {
map[c.id] = { label: c.label, scale: c.scale }
}
}
return map
}, [criteria])
const getMax = (criterionId: string) => {
const scale = criterionLabels[criterionId]?.scale
if (scale && scales && scales[scale]) return scales[scale].max
return 10
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-lg">{String(project.title || 'Untitled')}</CardTitle>
<CardDescription className="flex items-center gap-1 mt-1">
<Users className="h-3 w-3" />
{String(project.teamName || 'N/A')}
</CardDescription>
</div>
{/* Global score ring */}
{evaluation && globalScore != null && (
<ScoreRing score={globalScore} max={10} />
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Binary decision */}
{evaluation && binaryDecision != null && (
<div className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium',
binaryDecision
? 'bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
)}>
{binaryDecision ? (
<><ThumbsUp className="h-4 w-4" /> Recommended to advance</>
) : (
<><ThumbsDown className="h-4 w-4" /> Not recommended</>
)}
</div>
)}
{/* Country */}
{!!project.country && (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
{String(project.country)}
</div>
)}
{/* Description */}
{!!project.description && (
<p className="text-sm text-muted-foreground line-clamp-3">{String(project.description)}</p>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag: unknown, i: number) => (
<Badge key={i} variant="secondary" className="text-xs">
{String(tag)}
</Badge>
))}
</div>
)}
{/* Files */}
{files.length > 0 && (
<div className="text-sm text-muted-foreground flex items-center gap-1">
<FileText className="h-3 w-3" />
{files.length} file{files.length > 1 ? 's' : ''} attached
</div>
)}
<Separator />
{/* Evaluation scores with bars */}
{evaluation && scores && typeof scores === 'object' ? (
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Criterion Scores</p>
{Object.entries(scores).map(([criterionId, score]) => {
const numScore = Number(score)
if (isNaN(numScore)) return null
const label = criterionLabels[criterionId]?.label || criterionId
const max = getMax(criterionId)
return (
<div key={criterionId} className="space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate">{label}</span>
</div>
<ScoreBar score={numScore} max={max} isHighest={false} />
</div>
)
})}
</div>
) : evaluation ? (
<Badge variant="outline">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : (
<Badge variant="outline">
<Clock className="mr-1 h-3 w-3" />
Not yet evaluated
</Badge>
)}
</CardContent>
</Card>
)
}
function CriterionComparisonTable({
items,
criteria,
scales,
}: {
items: ComparisonItem[]
criteria: Criterion[]
scales: Record<string, { min: number; max: number }> | null
}) {
const scoreableCriteria = criteria.filter((c) => c.type !== 'section_header')
const getMax = (criterion: Criterion) => {
if (criterion.scale && scales && scales[criterion.scale]) return scales[criterion.scale].max
return 10
}
// Build score matrix
const getScore = (item: ComparisonItem, criterionId: string): number | null => {
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
if (!scores) return null
const val = scores[criterionId]
if (val == null) return null
const num = Number(val)
return isNaN(num) ? null : num
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criterion-by-Criterion Comparison</CardTitle>
<CardDescription>
Scores compared side by side. Highest score per criterion is highlighted.
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Criterion</TableHead>
{items.map((item) => (
<TableHead key={item.assignmentId} className="text-center min-w-[150px]">
{String((item.project as Record<string, unknown>).title || 'Untitled')}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{scoreableCriteria.map((criterion) => {
const max = getMax(criterion)
const itemScores = items.map((item) => getScore(item, criterion.id))
const validScores = itemScores.filter((s): s is number => s !== null)
const highestScore = validScores.length > 0 ? Math.max(...validScores) : null
const minScore = validScores.length > 0 ? Math.min(...validScores) : null
const divergence = highestScore !== null && minScore !== null ? highestScore - minScore : 0
const maxPossibleDivergence = max
const isDivergent = validScores.length >= 2 && maxPossibleDivergence > 0 && (divergence / maxPossibleDivergence) >= 0.4
return (
<TableRow key={criterion.id} className={cn(isDivergent && 'bg-amber-50 dark:bg-amber-950/20')}>
<TableCell className="font-medium">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm">{criterion.label}</span>
{criterion.weight && criterion.weight > 1 && (
<span className="text-xs text-muted-foreground ml-1">
(x{criterion.weight})
</span>
)}
{isDivergent && <Badge variant="outline" className="text-[10px] ml-1.5 text-amber-600 border-amber-300">Divergent</Badge>}
</div>
</TableCell>
{items.map((item, idx) => {
const score = itemScores[idx]
const isHighest = score !== null && score === highestScore && validScores.filter((s) => s === highestScore).length < validScores.length
return (
<TableCell key={item.assignmentId} className="text-center">
{score !== null ? (
<div className="flex flex-col items-center gap-1">
<span className={cn(
'text-sm font-medium tabular-nums',
isHighest && 'text-green-600 dark:text-green-400 font-bold',
getScoreTextColor(score, max)
)}>
{score}/{max}
</span>
<div className="w-full max-w-[80px] h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getScoreColor(score, max))}
style={{ width: `${(score / max) * 100}%` }}
/>
</div>
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
)
})}
{/* Global score row */}
<TableRow className="border-t-2 font-semibold">
<TableCell>Overall Score</TableCell>
{items.map((item) => {
const globalScore = item.evaluation?.globalScore as number | null | undefined
return (
<TableCell key={item.assignmentId} className="text-center">
{globalScore != null ? (
<span className={cn(
'text-base font-bold tabular-nums',
getScoreTextColor(globalScore, 10)
)}>
{globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
{/* Binary decision row */}
<TableRow>
<TableCell>Advance Decision</TableCell>
{items.map((item) => {
const decision = item.evaluation?.binaryDecision as boolean | null | undefined
return (
<TableCell key={item.assignmentId} className="text-center">
{decision != null ? (
decision ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Yes
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" /> No
</Badge>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)
})}
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
function CompareSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,377 +0,0 @@
'use client'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
import type { LiveVotingCriterion } from '@/types/round-settings'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data - reduced polling since SSE handles real-time
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId },
{ refetchInterval: 10000 }
)
const votingMode = data?.session.votingMode || 'simple'
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setCriterionScores({})
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Vote mutation
const vote = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote recorded')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Set selected score from existing vote
useEffect(() => {
if (data?.userVote) {
setSelectedScore(data.userVote.score)
// Restore criterion scores if available
if (data.userVote.criterionScoresJson) {
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
}
} else {
setSelectedScore(null)
setCriterionScores({})
}
}, [data?.userVote, data?.currentProject?.id])
// Initialize criterion scores with mid-values when criteria change
useEffect(() => {
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
const initial: Record<string, number> = {}
for (const c of criteria) {
initial[c.id] = Math.ceil(c.scale / 2)
}
setCriterionScores(initial)
}
}, [votingMode, criteria, criterionScores])
const handleSimpleVote = (score: number) => {
if (!data?.currentProject) return
setSelectedScore(score)
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
})
}
const handleCriteriaVote = () => {
if (!data?.currentProject) return
// Compute a rough overall score for the `score` field
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score: computedScore,
criterionScores,
})
}
const computeWeightedScore = (): number => {
if (criteria.length === 0) return 0
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
}
if (isLoading) {
return <JuryVotingSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
const isVoting = data.session.status === 'IN_PROGRESS'
const hasVoted = !!data.userVote
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-6 w-6 text-primary" />
<CardTitle>Live Voting</CardTitle>
</div>
<CardDescription>
{data.round.program.name} - {data.round.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Voting UI - Simple mode */}
{votingMode === 'simple' && (
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleSimpleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
)}
{/* Voting UI - Criteria mode */}
{votingMode === 'criteria' && criteria.length > 0 && (
<div className="space-y-4">
<p className="text-sm font-medium text-center">Score Each Criterion</p>
{criteria.map((c) => (
<div key={c.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.label}</p>
{c.description && (
<p className="text-xs text-muted-foreground truncate">
{c.description}
</p>
)}
</div>
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
{criterionScores[c.id] || 1}/{c.scale}
</span>
</div>
<Slider
min={1}
max={c.scale}
step={1}
value={[criterionScores[c.id] || 1]}
onValueChange={([val]) => {
setCriterionScores((prev) => ({
...prev,
[c.id]: val,
}))
}}
disabled={vote.isPending || countdown === 0}
/>
</div>
))}
{/* Computed weighted score */}
<div className="flex items-center justify-between border-t pt-3">
<p className="text-sm font-medium">Weighted Score</p>
<span className="text-2xl font-bold text-primary">
{computeWeightedScore().toFixed(1)}
</span>
</div>
<Button
className="w-full"
onClick={handleCriteriaVote}
disabled={vote.isPending || countdown === 0}
>
<Send className="mr-2 h-4 w-4" />
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
)}
{/* Vote status */}
{hasVoted && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'The admin will start voting for the next project.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Mobile-friendly footer */}
<div className="flex items-center justify-center gap-2 mt-4">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-400" />
) : (
<WifiOff className="h-3 w-3 text-red-400" />
)}
<p className="text-white/60 text-sm">
MOPC Live Voting {isConnected ? '- Connected' : '- Reconnecting...'}
</p>
</div>
</div>
)
}
function JuryVotingSkeleton() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Skeleton className="h-6 w-32 mx-auto" />
<Skeleton className="h-4 w-48 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function JuryLiveVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <JuryVotingContent sessionId={sessionId} />
}

View File

@@ -60,17 +60,25 @@ async function JuryDashboardContent() {
country: true,
},
},
round: {
stage: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
windowOpenAt: true,
windowCloseAt: true,
track: {
select: {
name: true,
year: true,
pipeline: {
select: {
program: {
select: {
name: true,
year: true,
},
},
},
},
},
},
},
@@ -88,7 +96,7 @@ async function JuryDashboardContent() {
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ stage: { windowCloseAt: 'asc' } },
{ createdAt: 'asc' },
],
}),
@@ -98,7 +106,7 @@ async function JuryDashboardContent() {
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
stageId: true,
extendedUntil: true,
},
}),
@@ -118,49 +126,49 @@ async function JuryDashboardContent() {
const completionRate =
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
// Group assignments by round
const assignmentsByRound = assignments.reduce(
// Group assignments by stage
const assignmentsByStage = assignments.reduce(
(acc, assignment) => {
const roundId = assignment.round.id
if (!acc[roundId]) {
acc[roundId] = {
round: assignment.round,
const stageId = assignment.stage.id
if (!acc[stageId]) {
acc[stageId] = {
stage: assignment.stage,
assignments: [],
}
}
acc[roundId].assignments.push(assignment)
acc[stageId].assignments.push(assignment)
return acc
},
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
{} as Record<string, { stage: (typeof assignments)[0]['stage']; assignments: typeof assignments }>
)
const graceByRound = new Map<string, Date>()
const graceByStage = new Map<string, Date>()
for (const gp of gracePeriods) {
const existing = graceByRound.get(gp.roundId)
const existing = graceByStage.get(gp.stageId)
if (!existing || gp.extendedUntil > existing) {
graceByRound.set(gp.roundId, gp.extendedUntil)
graceByStage.set(gp.stageId, gp.extendedUntil)
}
}
// Active rounds (voting window open)
// Active stages (voting window open)
const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter(
({ round }) =>
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
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
)
// Find next unevaluated assignment in an active round
// Find next unevaluated assignment in an active stage
const nextUnevaluated = assignments.find((a) => {
const isActive =
a.round.status === 'ACTIVE' &&
a.round.votingStartAt &&
a.round.votingEndAt &&
new Date(a.round.votingStartAt) <= now &&
new Date(a.round.votingEndAt) >= now
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT'
return isActive && isIncomplete
})
@@ -168,14 +176,14 @@ async function JuryDashboardContent() {
// Recent assignments for the quick list (latest 5)
const recentAssignments = assignments.slice(0, 6)
// Get active round remaining count
// Get active stage remaining count
const activeRemaining = assignments.filter((a) => {
const isActive =
a.round.status === 'ACTIVE' &&
a.round.votingStartAt &&
a.round.votingEndAt &&
new Date(a.round.votingStartAt) <= now &&
new Date(a.round.votingEndAt) >= now
a.stage.status === 'STAGE_ACTIVE' &&
a.stage.windowOpenAt &&
a.stage.windowCloseAt &&
new Date(a.stage.windowOpenAt) <= now &&
new Date(a.stage.windowCloseAt) >= now
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
return isActive && isIncomplete
}).length
@@ -233,7 +241,7 @@ async function JuryDashboardContent() {
</div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
<Link
href="/jury/assignments"
href="/jury/stages"
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">
@@ -245,7 +253,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/compare"
href="/jury/stages"
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">
@@ -285,7 +293,7 @@ async function JuryDashboardContent() {
</div>
</div>
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
<Link href={`/jury/projects/${nextUnevaluated.project.id}/evaluate`}>
<Link href={`/jury/stages/${nextUnevaluated.stage.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -355,7 +363,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/assignments">
<Link href="/jury/stages">
View all
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
@@ -370,11 +378,11 @@ async function JuryDashboardContent() {
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
assignment.stage.status === 'STAGE_ACTIVE' &&
assignment.stage.windowOpenAt &&
assignment.stage.windowCloseAt &&
new Date(assignment.stage.windowOpenAt) <= now &&
new Date(assignment.stage.windowCloseAt) >= now
return (
<div
@@ -386,7 +394,7 @@ async function JuryDashboardContent() {
)}
>
<Link
href={`/jury/projects/${assignment.project.id}`}
href={`/jury/stages/${assignment.stage.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">
@@ -397,7 +405,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.round.name}
{assignment.stage.name}
</Badge>
</div>
</Link>
@@ -417,19 +425,19 @@ async function JuryDashboardContent() {
)}
{isCompleted ? (
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
<Link href={`/jury/projects/${assignment.project.id}/evaluation`}>
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluation`}>
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/projects/${assignment.project.id}/evaluate`}>
<Link href={`/jury/stages/${assignment.stage.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/projects/${assignment.project.id}`}>
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}>
View
</Link>
</Button>
@@ -470,7 +478,7 @@ async function JuryDashboardContent() {
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href="/jury/assignments"
href="/jury/stages"
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">
@@ -482,7 +490,7 @@ async function JuryDashboardContent() {
</div>
</Link>
<Link
href="/jury/compare"
href="/jury/stages"
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">
@@ -501,8 +509,8 @@ async function JuryDashboardContent() {
{/* Right column */}
<div className="lg:col-span-5 space-y-4">
{/* Active Rounds */}
{activeRounds.length > 0 && (
{/* Active Stages */}
{activeStages.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" />
@@ -512,28 +520,29 @@ async function JuryDashboardContent() {
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
</div>
<div>
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
<CardDescription className="mt-0.5">
Rounds currently open for evaluation
Stages currently open for evaluation
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{activeRounds.map(({ round, assignments: roundAssignments }) => {
const roundCompleted = roundAssignments.filter(
{activeStages.map(({ stage, assignments: stageAssignments }) => {
const stageCompleted = stageAssignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const roundTotal = roundAssignments.length
const roundProgress =
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
const isAlmostDone = roundProgress >= 80
const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null)
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 isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
const program = stage.track.pipeline.program
return (
<div
key={round.id}
key={stage.id}
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent
@@ -543,9 +552,9 @@ async function JuryDashboardContent() {
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{stage.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{round.program.name} &middot; {round.program.year}
{program.name} &middot; {program.year}
</p>
</div>
{isAlmostDone ? (
@@ -559,13 +568,13 @@ async function JuryDashboardContent() {
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-semibold tabular-nums">
{roundCompleted}/{roundTotal}
{stageCompleted}/{stageTotal}
</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: `${roundProgress}%` }}
style={{ width: `${stageProgress}%` }}
/>
</div>
</div>
@@ -576,16 +585,16 @@ async function JuryDashboardContent() {
deadline={deadline}
label="Deadline:"
/>
{round.votingEndAt && (
{stage.windowCloseAt && (
<span className="text-xs text-muted-foreground">
({formatDateOnly(round.votingEndAt)})
({formatDateOnly(stage.windowCloseAt)})
</span>
)}
</div>
)}
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/assignments?round=${round.id}`}>
<Link href={`/jury/stages/${stage.id}/assignments`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
@@ -598,15 +607,15 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* No active rounds */}
{activeRounds.length === 0 && (
{/* No active stages */}
{activeStages.length === 0 && (
<AnimatedCard index={8}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
<Clock className="h-6 w-6 text-brand-teal/70" />
</div>
<p className="font-semibold text-sm">No active voting rounds</p>
<p className="font-semibold text-sm">No active voting stages</p>
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
Check back later when a voting window opens
</p>
@@ -615,8 +624,8 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* Completion Summary by Round */}
{Object.keys(assignmentsByRound).length > 0 && (
{/* Completion Summary by Stage */}
{Object.keys(assignmentsByStage).length > 0 && (
<AnimatedCard index={9}>
<Card>
<CardHeader className="pb-3">
@@ -624,18 +633,18 @@ async function JuryDashboardContent() {
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
<CardTitle className="text-lg">Round Summary</CardTitle>
<CardTitle className="text-lg">Stage Summary</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => {
const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
const total = roundAssignments.length
{Object.values(assignmentsByStage).map(({ stage, assignments: stageAssignments }) => {
const done = stageAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
const total = stageAssignments.length
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return (
<div key={round.id} className="space-y-2">
<div key={stage.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate">{round.name}</span>
<span className="font-medium truncate">{stage.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,366 +0,0 @@
'use client'
import { useState } from 'react'
import { useParams, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
BarChart3,
MessageSquare,
Send,
Loader2,
Lock,
User,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, cn, getInitials } from '@/lib/utils'
export default function DiscussionPage() {
const params = useParams()
const searchParams = useSearchParams()
const projectId = params.id as string
const roundId = searchParams.get('roundId') || ''
const [commentText, setCommentText] = useState('')
const utils = trpc.useUtils()
// Fetch peer summary
const { data: peerSummary, isLoading: loadingSummary } =
trpc.evaluation.getPeerSummary.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Fetch discussion thread
const { data: discussion, isLoading: loadingDiscussion } =
trpc.evaluation.getDiscussion.useQuery(
{ projectId, roundId },
{ enabled: !!roundId }
)
// Add comment mutation
const addCommentMutation = trpc.evaluation.addComment.useMutation({
onSuccess: () => {
utils.evaluation.getDiscussion.invalidate({ projectId, roundId })
toast.success('Comment added')
setCommentText('')
},
onError: (e) => toast.error(e.message),
})
const handleSubmitComment = () => {
if (!commentText.trim()) {
toast.error('Please enter a comment')
return
}
addCommentMutation.mutate({
projectId,
roundId,
content: commentText.trim(),
})
}
const isLoading = loadingSummary || loadingDiscussion
if (!roundId) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<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">
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No round specified</p>
<p className="text-sm text-muted-foreground">
Please access the discussion from your assignments page.
</p>
</CardContent>
</Card>
</div>
)
}
if (isLoading) {
return <DiscussionSkeleton />
}
// Parse peer summary data
const summary = peerSummary as Record<string, unknown> | undefined
const averageScore = summary ? Number(summary.averageScore || 0) : 0
const scoreRange = summary?.scoreRange as { min: number; max: number } | undefined
const evaluationCount = summary ? Number(summary.evaluationCount || 0) : 0
const individualScores = (summary?.scores || summary?.individualScores) as
| Array<number>
| undefined
// Parse discussion data
const discussionData = discussion as Record<string, unknown> | undefined
const comments = (discussionData?.comments || []) as Array<{
id: string
user: { id: string; name: string | null; email: string }
content: string
createdAt: string
}>
const discussionStatus = String(discussionData?.status || 'OPEN')
const isClosed = discussionStatus === 'CLOSED'
const closedAt = discussionData?.closedAt as string | undefined
const closedBy = discussionData?.closedBy as Record<string, unknown> | undefined
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Project Discussion
</h1>
<p className="text-muted-foreground">
Peer review discussion and anonymized score summary
</p>
</div>
{/* Peer Summary Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Peer Summary
</CardTitle>
<CardDescription>
Anonymized scoring overview across all evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{averageScore.toFixed(1)}</p>
<p className="text-xs text-muted-foreground">Average Score</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">
{scoreRange
? `${scoreRange.min.toFixed(1)} - ${scoreRange.max.toFixed(1)}`
: '--'}
</p>
<p className="text-xs text-muted-foreground">Score Range</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-2xl font-bold">{evaluationCount}</p>
<p className="text-xs text-muted-foreground">Evaluations</p>
</div>
</div>
{/* Anonymized score bars */}
{individualScores && individualScores.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Anonymized Individual Scores</p>
<div className="flex items-end gap-2 h-24">
{individualScores.map((score, i) => {
const maxPossible = scoreRange?.max || 10
const height =
maxPossible > 0
? Math.max((score / maxPossible) * 100, 4)
: 4
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-1"
>
<span className="text-[10px] font-medium tabular-nums">
{score.toFixed(1)}
</span>
<div
className={cn(
'w-full rounded-t transition-all',
score >= averageScore
? 'bg-primary/60'
: 'bg-muted-foreground/30'
)}
style={{ height: `${height}%` }}
/>
<span className="text-[10px] text-muted-foreground">
#{i + 1}
</span>
</div>
)
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* Discussion Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Discussion
</CardTitle>
{isClosed && (
<Badge variant="secondary" className="flex items-center gap-1">
<Lock className="h-3 w-3" />
Closed
{closedAt && (
<span className="ml-1">- {formatDate(closedAt)}</span>
)}
</Badge>
)}
</div>
<CardDescription>
{isClosed
? 'This discussion has been closed.'
: 'Share your thoughts with fellow jurors about this project.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Comments */}
{comments.length > 0 ? (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{/* Avatar */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
{comment.user?.name
? getInitials(comment.user.name)
: <User className="h-4 w-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{comment.user?.name || 'Anonymous Juror'}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-1 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-2 text-sm text-muted-foreground">
No comments yet. Be the first to start the discussion.
</p>
</div>
)}
{/* Comment input */}
{!isClosed ? (
<div className="space-y-2 border-t pt-4">
<Textarea
placeholder="Write your comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={3}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmitComment}
disabled={
addCommentMutation.isPending || !commentText.trim()
}
>
{addCommentMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Post Comment
</Button>
</div>
</div>
) : (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground text-center">
This discussion is closed and no longer accepts new comments.
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function DiscussionSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40 mt-2" />
</div>
{/* Peer summary skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
{/* Discussion skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,349 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
// Define the criterion type for the evaluation form
interface Criterion {
id: string
label: string
description?: string
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
scale?: number
weight?: number
required?: boolean
maxLength?: number
placeholder?: string
trueLabel?: string
falseLabel?: string
condition?: {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
sectionId?: string
}
async function EvaluateContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
_count: {
select: { files: true },
},
},
})
if (!project) {
notFound()
}
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<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" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const round = assignment.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed = round.votingEndAt && isPast(new Date(round.votingEndAt))
// Check for grace period
const gracePeriod = await prisma.gracePeriod.findFirst({
where: {
roundId: round.id,
userId,
OR: [{ projectId: null }, { projectId }],
extendedUntil: { gte: now },
},
})
const hasGracePeriod = !!gracePeriod
const effectiveVotingOpen = isVotingOpen || hasGracePeriod
// Check if already submitted
const evaluation = assignment.evaluation
const isSubmitted =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
if (isSubmitted) {
redirect(`/jury/projects/${projectId}/evaluation`)
}
// Check COI status
const coiRecord = await prisma.conflictOfInterest.findUnique({
where: { assignmentId: assignment.id },
})
const coiStatus = coiRecord
? { hasConflict: coiRecord.hasConflict, declared: true }
: { hasConflict: false, declared: false }
// Get evaluation form criteria
const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">Evaluation Form Not Available</p>
<p className="text-sm text-muted-foreground">
The evaluation criteria for this round have not been configured yet.
Please check back later.
</p>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from JSON
const criteria: Criterion[] = (evaluationForm.criteriaJson as unknown as Criterion[]) || []
// Handle voting not open
if (!effectiveVotingOpen) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">
{isVotingUpcoming ? 'Voting Not Yet Open' : 'Voting Period Closed'}
</p>
<p className="text-sm text-muted-foreground">
{isVotingUpcoming
? 'The voting window for this round has not started yet.'
: 'The voting window for this round has ended.'}
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Back button and project summary */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-xl font-semibold">Evaluate: {project.title}</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
</div>
{/* Quick file access */}
{project.files.length > 0 && (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
</span>
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${projectId}`}>View Files</Link>
</Button>
</div>
)}
</div>
{/* Grace period notice */}
{hasGracePeriod && gracePeriod && (
<Card className="border-amber-500 bg-amber-500/5">
<CardContent className="py-3">
<div className="flex items-center gap-2 text-amber-600">
<Clock className="h-4 w-4" />
<span className="text-sm font-medium">
You have a grace period extension until{' '}
{new Date(gracePeriod.extendedUntil).toLocaleString()}
</span>
</div>
</CardContent>
</Card>
)}
{/* Project Files */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files || 0}
/>
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
criteria={criteria}
initialData={
evaluation
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number | string | boolean
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
feedbackText: evaluation.feedbackText,
status: evaluation.status,
}
: undefined
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
coiStatus={coiStatus}
/>
</div>
)
}
function EvaluateSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-80" />
<Skeleton className="h-4 w-32" />
</div>
<Card>
<CardContent className="p-6 space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluatePage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluateSkeleton />}>
<EvaluateContent projectId={id} />
</Suspense>
)
}

View File

@@ -1,439 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
ThumbsUp,
ThumbsDown,
Calendar,
Users,
Star,
AlertCircle,
} from 'lucide-react'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { format } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
interface Criterion {
id: string
label: string
description?: string
scale: number
weight?: number
required?: boolean
}
async function EvaluationContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
teamName: true,
_count: { select: { files: true } },
},
})
if (!project) {
notFound()
}
// Find next unevaluated project for "Next Project" navigation
const now = new Date()
const nextAssignment = await prisma.assignment.findFirst({
where: {
userId,
id: { not: assignment?.id ?? undefined },
round: {
status: 'ACTIVE',
votingStartAt: { lte: now },
votingEndAt: { gte: now },
},
OR: [
{ evaluation: null },
{ evaluation: { status: { in: ['NOT_STARTED', 'DRAFT'] } } },
],
},
select: {
project: { select: { id: true, title: true } },
},
orderBy: { round: { votingEndAt: 'asc' } },
})
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<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" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const evaluation = assignment.evaluation
if (!evaluation || evaluation.status === 'NOT_STARTED') {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Evaluation Found</p>
<p className="text-sm text-muted-foreground">
You haven&apos;t submitted an evaluation for this project yet.
</p>
<Button asChild className="mt-4">
<Link href={`/jury/projects/${projectId}/evaluate`}>
Start Evaluation
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from the evaluation form
const criteria: Criterion[] =
(evaluation.form.criteriaJson as unknown as Criterion[]) || []
const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = assignment.round
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
My Evaluation: {project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<Badge
variant="default"
className="w-fit bg-green-600 hover:bg-green-700"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{evaluation.status === 'LOCKED' ? 'Locked' : 'Submitted'}
</Badge>
</div>
{evaluation.submittedAt && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Submitted on {format(new Date(evaluation.submittedAt), 'PPP')} at{' '}
{format(new Date(evaluation.submittedAt), 'p')}
</div>
)}
</div>
<Separator />
{/* Project Documents */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files ?? 0}
/>
{/* Criteria scores */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criteria Scores</CardTitle>
<CardDescription>
Your ratings for each evaluation criterion
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{criteria.map((criterion) => {
const score = criterionScores[criterion.id]
return (
<div key={criterion.id} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{criterion.label}</p>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{score}</span>
<span className="text-muted-foreground">
/ {criterion.scale}
</span>
</div>
</div>
{/* Visual score bar */}
<div className="flex gap-1">
{Array.from(
{ length: criterion.scale },
(_, i) => i + 1
).map((num) => (
<div
key={num}
className={`h-2 flex-1 rounded-full ${
num <= score
? 'bg-primary'
: 'bg-muted'
}`}
/>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<span className="text-3xl font-bold text-primary">
{evaluation.globalScore}
</span>
</div>
<div>
<p className="text-lg font-medium">out of 10</p>
<p className="text-sm text-muted-foreground">
{evaluation.globalScore && evaluation.globalScore >= 8
? 'Excellent'
: evaluation.globalScore && evaluation.globalScore >= 6
? 'Good'
: evaluation.globalScore && evaluation.globalScore >= 4
? 'Average'
: 'Below Average'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recommendation */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
</CardHeader>
<CardContent>
<div
className={`flex items-center gap-3 rounded-lg p-4 ${
evaluation.binaryDecision
? 'bg-green-500/10 text-green-700 dark:text-green-400'
: 'bg-red-500/10 text-red-700 dark:text-red-400'
}`}
>
{evaluation.binaryDecision ? (
<>
<ThumbsUp className="h-8 w-8" />
<div>
<p className="font-semibold">Recommended to Advance</p>
<p className="text-sm opacity-80">
You voted YES for this project to advance to the next round
</p>
</div>
</>
) : (
<>
<ThumbsDown className="h-8 w-8" />
<div>
<p className="font-semibold">Not Recommended</p>
<p className="text-sm opacity-80">
You voted NO for this project to advance
</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4">
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
</div>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" asChild>
<Link href={`/jury/projects/${projectId}`}>View Project Details</Link>
</Button>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/jury/assignments">All Assignments</Link>
</Button>
{nextAssignment && (
<Button asChild>
<Link href={`/jury/projects/${nextAssignment.project.id}/evaluate`}>
Next Project
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
</div>
</div>
</div>
)
}
function EvaluationSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-px w-full" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-16" />
</div>
<Skeleton className="h-2 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-32 rounded-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluationViewPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluationSkeleton />}>
<EvaluationContent projectId={id} />
</Suspense>
)
}

View File

@@ -1,507 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { FileViewerSkeleton } from '@/components/shared/file-viewer'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import {
ArrowLeft,
ArrowRight,
Users,
Calendar,
Clock,
CheckCircle2,
Edit3,
Tag,
FileText,
AlertCircle,
} from 'lucide-react'
import { formatDistanceToNow, format, isPast, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
async function ProjectContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: true,
round: {
include: {
program: {
select: { name: true, year: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
// Get project details
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
},
})
if (!project) {
notFound()
}
if (!assignment) {
// User is not assigned to this project
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
<Button asChild className="mt-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</CardContent>
</Card>
)
}
const evaluation = assignment.evaluation
const round = assignment.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed =
round.votingEndAt && isPast(new Date(round.votingEndAt))
// Determine evaluation status
const getEvaluationStatus = () => {
if (!evaluation)
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
switch (evaluation.status) {
case 'DRAFT':
return { label: 'In Progress', variant: 'secondary' as const, icon: Edit3 }
case 'SUBMITTED':
return { label: 'Submitted', variant: 'default' as const, icon: CheckCircle2 }
case 'LOCKED':
return { label: 'Locked', variant: 'default' as const, icon: CheckCircle2 }
default:
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
}
}
const status = getEvaluationStatus()
const StatusIcon = status.icon
const canEvaluate =
isVotingOpen &&
evaluation?.status !== 'SUBMITTED' &&
evaluation?.status !== 'LOCKED'
const canViewEvaluation =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Project Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.year} Edition</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Badge variant={status.variant} className="w-fit">
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
{round.votingEndAt && (
<DeadlineDisplay
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
)}
</div>
</div>
{/* Tags */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
<Tag className="mr-1 h-3 w-3" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-3">
{canEvaluate && (
<Button asChild>
<Link href={`/jury/projects/${project.id}/evaluate`}>
{evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
{canViewEvaluation && (
<Button variant="secondary" asChild>
<Link href={`/jury/projects/${project.id}/evaluation`}>
View My Evaluation
</Link>
</Button>
)}
{!isVotingOpen && !canViewEvaluation && (
<Button disabled>
{isVotingUpcoming
? 'Voting Not Yet Open'
: isVotingClosed
? 'Voting Closed'
: 'Evaluation Unavailable'}
</Button>
)}
</div>
<Separator />
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Description - takes 2 columns on large screens */}
<div className="lg:col-span-2 space-y-6">
{/* Description */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Description
</CardTitle>
</CardHeader>
<CardContent>
{project.description ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
) : (
<p className="text-muted-foreground italic">
No description provided
</p>
)}
</CardContent>
</Card>
{/* Files */}
<Suspense fallback={<FileViewerSkeleton />}>
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
</Suspense>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Round Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Calendar className="h-4 w-4 text-blue-500" />
</div>
Round Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Round</span>
<span className="text-sm font-medium">{round.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Program</span>
<span className="text-sm font-medium">{round.program.name}</span>
</div>
<Separator />
{round.votingStartAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Opens</span>
<span className="text-sm">
{format(new Date(round.votingStartAt), 'PPp')}
</span>
</div>
)}
{round.votingEndAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Closes</span>
<span className="text-sm">
{format(new Date(round.votingEndAt), 'PPp')}
</span>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<RoundStatusBadge
status={round.status}
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
</div>
</CardContent>
</Card>
{/* Evaluation Progress */}
{evaluation && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
</div>
Your Evaluation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={status.variant}>
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</div>
{evaluation.status === 'DRAFT' && (
<p className="text-sm text-muted-foreground">
Last saved{' '}
{formatDistanceToNow(new Date(evaluation.updatedAt), {
addSuffix: true,
})}
</p>
)}
{evaluation.status === 'SUBMITTED' && evaluation.submittedAt && (
<p className="text-sm text-muted-foreground">
Submitted{' '}
{formatDistanceToNow(new Date(evaluation.submittedAt), {
addSuffix: true,
})}
</p>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function DeadlineDisplay({
votingStartAt,
votingEndAt,
}: {
votingStartAt: Date | null
votingEndAt: Date
}) {
const now = new Date()
const endDate = new Date(votingEndAt)
const startDate = votingStartAt ? new Date(votingStartAt) : null
if (startDate && isFuture(startDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
Opens {format(startDate, 'PPp')}
</div>
)
}
if (isPast(endDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
Closed {formatDistanceToNow(endDate, { addSuffix: true })}
</div>
)
}
const daysRemaining = Math.ceil(
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
)
const isUrgent = daysRemaining <= 3
return (
<div
className={`flex items-center gap-1 text-sm ${
isUrgent ? 'text-amber-600 font-medium' : 'text-muted-foreground'
}`}
>
<Clock className="h-3 w-3" />
{daysRemaining <= 0
? `Due ${formatDistanceToNow(endDate, { addSuffix: true })}`
: `${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining`}
</div>
)
}
function RoundStatusBadge({
status,
votingStartAt,
votingEndAt,
}: {
status: string
votingStartAt: Date | null
votingEndAt: Date | null
}) {
const now = new Date()
const isVotingOpen =
status === 'ACTIVE' &&
votingStartAt &&
votingEndAt &&
new Date(votingStartAt) <= now &&
new Date(votingEndAt) >= now
if (isVotingOpen) {
return <Badge variant="default">Voting Open</Badge>
}
if (status === 'ACTIVE' && votingStartAt && isFuture(new Date(votingStartAt))) {
return <Badge variant="secondary">Upcoming</Badge>
}
if (status === 'ACTIVE' && votingEndAt && isPast(new Date(votingEndAt))) {
return <Badge variant="outline">Voting Closed</Badge>
}
return <Badge variant="secondary">{status}</Badge>
}
function ProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2 sm:items-end">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-px w-full" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
<FileViewerSkeleton />
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
)
}
export default async function ProjectDetailPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<ProjectSkeleton />}>
<ProjectContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,368 @@
'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

@@ -0,0 +1,311 @@
'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

@@ -0,0 +1,269 @@
'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

@@ -0,0 +1,199 @@
'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

@@ -0,0 +1,235 @@
'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

@@ -0,0 +1,217 @@
'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

@@ -0,0 +1,247 @@
'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>
)
}