Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

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

View File

@@ -1,327 +1,327 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
GripVertical,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export default function JuryAwardVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const utils = trpc.useUtils()
const { data, isLoading, refetch } =
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
const submitVote = trpc.specialAward.submitVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
},
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [rankedIds, setRankedIds] = useState<string[]>([])
// Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null)
} else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
.map((v) => v.projectId)
setRankedIds(sorted)
}
}
const handleSubmitPickWinner = async () => {
if (!selectedProjectId) return
try {
await submitVote.mutateAsync({
awardId,
votes: [{ projectId: selectedProjectId }],
})
toast.success('Vote submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit vote'
)
}
}
const handleSubmitRanked = async () => {
if (rankedIds.length === 0) return
try {
await submitVote.mutateAsync({
awardId,
votes: rankedIds.map((projectId, index) => ({
projectId,
rank: index + 1,
})),
})
toast.success('Rankings submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit rankings'
)
}
}
const toggleRanked = (projectId: string) => {
if (rankedIds.includes(projectId)) {
setRankedIds(rankedIds.filter((id) => id !== projectId))
} else {
const maxPicks = data?.award.maxRankedPicks || 5
if (rankedIds.length < maxPicks) {
setRankedIds([...rankedIds, projectId])
}
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!data) return null
const { award, projects, myVotes } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/awards">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Awards
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={isVotingOpen ? 'default' : 'secondary'}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
</div>
{award.criteriaText && (
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
)}
</div>
{!isVotingOpen ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Voting is not open</p>
<p className="text-sm text-muted-foreground">
Check back when voting opens for this award
</p>
</CardContent>
</Card>
) : award.scoringMode === 'PICK_WINNER' ? (
/* PICK_WINNER Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select one project as the winner
</p>
<div className="grid gap-3 sm:grid-cols-2">
{projects.map((project) => (
<Card
key={project.id}
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => setSelectedProjectId(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">{project.title}</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitPickWinner}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</div>
) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select and rank your top {award.maxRankedPicks || 5} projects. Click
to add/remove, drag to reorder.
</p>
{/* Selected rankings */}
{rankedIds.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Your Rankings</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{rankedIds.map((id, index) => {
const project = projects.find((p) => p.id === id)
if (!project) return null
return (
<div
key={id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<span className="font-bold text-lg w-8 text-center">
{index + 1}
</span>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRanked(id)}
>
Remove
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Available projects */}
<div className="grid gap-3 sm:grid-cols-2">
{projects
.filter((p) => !rankedIds.includes(p.id))
.map((project) => (
<Card
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => toggleRanked(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{project.title}
</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitRanked}
disabled={rankedIds.length === 0 || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
</Button>
</div>
</div>
) : (
/* SCORED Mode — redirect to evaluation */
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Scored Award</p>
<p className="text-sm text-muted-foreground">
This award uses the evaluation system. Check your evaluation
assignments.
</p>
</CardContent>
</Card>
)}
</div>
)
}
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
GripVertical,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export default function JuryAwardVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const utils = trpc.useUtils()
const { data, isLoading, refetch } =
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
const submitVote = trpc.specialAward.submitVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
},
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [rankedIds, setRankedIds] = useState<string[]>([])
// Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
if (data.award.scoringMode === 'PICK_WINNER') {
setSelectedProjectId(data.myVotes[0]?.projectId || null)
} else if (data.award.scoringMode === 'RANKED') {
const sorted = [...data.myVotes]
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
.map((v) => v.projectId)
setRankedIds(sorted)
}
}
const handleSubmitPickWinner = async () => {
if (!selectedProjectId) return
try {
await submitVote.mutateAsync({
awardId,
votes: [{ projectId: selectedProjectId }],
})
toast.success('Vote submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit vote'
)
}
}
const handleSubmitRanked = async () => {
if (rankedIds.length === 0) return
try {
await submitVote.mutateAsync({
awardId,
votes: rankedIds.map((projectId, index) => ({
projectId,
rank: index + 1,
})),
})
toast.success('Rankings submitted')
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to submit rankings'
)
}
}
const toggleRanked = (projectId: string) => {
if (rankedIds.includes(projectId)) {
setRankedIds(rankedIds.filter((id) => id !== projectId))
} else {
const maxPicks = data?.award.maxRankedPicks || 5
if (rankedIds.length < maxPicks) {
setRankedIds([...rankedIds, projectId])
}
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!data) return null
const { award, projects, myVotes } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/awards">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Awards
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={isVotingOpen ? 'default' : 'secondary'}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
</div>
{award.criteriaText && (
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
)}
</div>
{!isVotingOpen ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Voting is not open</p>
<p className="text-sm text-muted-foreground">
Check back when voting opens for this award
</p>
</CardContent>
</Card>
) : award.scoringMode === 'PICK_WINNER' ? (
/* PICK_WINNER Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select one project as the winner
</p>
<div className="grid gap-3 sm:grid-cols-2">
{projects.map((project) => (
<Card
key={project.id}
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => setSelectedProjectId(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">{project.title}</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitPickWinner}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</div>
) : award.scoringMode === 'RANKED' ? (
/* RANKED Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select and rank your top {award.maxRankedPicks || 5} projects. Click
to add/remove, drag to reorder.
</p>
{/* Selected rankings */}
{rankedIds.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Your Rankings</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{rankedIds.map((id, index) => {
const project = projects.find((p) => p.id === id)
if (!project) return null
return (
<div
key={id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<span className="font-bold text-lg w-8 text-center">
{index + 1}
</span>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRanked(id)}
>
Remove
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Available projects */}
<div className="grid gap-3 sm:grid-cols-2">
{projects
.filter((p) => !rankedIds.includes(p.id))
.map((project) => (
<Card
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => toggleRanked(project.id)}
>
<CardHeader className="pb-2">
<CardTitle className="text-base">
{project.title}
</CardTitle>
<CardDescription>{project.teamName}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
{project.country}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitRanked}
disabled={rankedIds.length === 0 || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
</Button>
</div>
</div>
) : (
/* SCORED Mode — redirect to evaluation */
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Scored Award</p>
<p className="text-sm text-muted-foreground">
This award uses the evaluation system. Check your evaluation
assignments.
</p>
</CardContent>
</Card>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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