Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,480 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn, formatDate, truncate } from '@/lib/utils'
|
||||
|
||||
function getCriteriaProgress(evaluation: {
|
||||
criterionScoresJson: unknown
|
||||
form: { criteriaJson: unknown }
|
||||
} | null): { completed: number; total: number } | null {
|
||||
if (!evaluation || !evaluation.form?.criteriaJson) return null
|
||||
const criteria = evaluation.form.criteriaJson as Array<{ id: string; type?: string }>
|
||||
// Only count scoreable criteria (exclude section_header)
|
||||
const scoreable = criteria.filter((c) => c.type !== 'section_header')
|
||||
const total = scoreable.length
|
||||
if (total === 0) return null
|
||||
const scores = (evaluation.criterionScoresJson || {}) as Record<string, unknown>
|
||||
const completed = scoreable.filter((c) => scores[c.id] != null && scores[c.id] !== '').length
|
||||
return { completed, total }
|
||||
}
|
||||
|
||||
function getDeadlineUrgency(deadline: Date | null): { label: string; className: string } | null {
|
||||
if (!deadline) return null
|
||||
const now = new Date()
|
||||
const diff = deadline.getTime() - now.getTime()
|
||||
const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
if (daysLeft < 0) return { label: 'Overdue', className: 'text-muted-foreground' }
|
||||
if (daysLeft <= 2) return { label: `${daysLeft}d left`, className: 'text-red-600 font-semibold' }
|
||||
if (daysLeft <= 7) return { label: `${daysLeft}d left`, className: 'text-amber-600 font-medium' }
|
||||
return { label: `${daysLeft}d left`, className: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
async function AssignmentsContent({
|
||||
roundId,
|
||||
}: {
|
||||
roundId?: string
|
||||
}) {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get assignments, optionally filtered by round
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(roundId ? { roundId } : {}),
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
fileType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
round: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
votingStartAt: true,
|
||||
votingEndAt: true,
|
||||
program: {
|
||||
select: {
|
||||
name: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
updatedAt: true,
|
||||
criterionScoresJson: true,
|
||||
form: {
|
||||
select: {
|
||||
criteriaJson: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ round: { votingEndAt: 'asc' } },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No assignments found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{roundId
|
||||
? 'No projects assigned to you for this round'
|
||||
: "You don't have any project assignments yet"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const completedCount = assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length
|
||||
const inProgressCount = assignments.filter(a => a.evaluation?.status === 'DRAFT').length
|
||||
const pendingCount = assignments.filter(a => !a.evaluation).length
|
||||
const overallProgress = assignments.length > 0 ? Math.round((completedCount / assignments.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress Summary */}
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{inProgressCount}</p>
|
||||
<p className="text-xs text-muted-foreground">In Progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Progress value={overallProgress} className="h-2 w-32" gradient />
|
||||
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Deadline</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((assignment) => {
|
||||
const evaluation = assignment.evaluation
|
||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||
const isDraft = evaluation?.status === 'DRAFT'
|
||||
const isVotingOpen =
|
||||
assignment.round.status === 'ACTIVE' &&
|
||||
assignment.round.votingStartAt &&
|
||||
assignment.round.votingEndAt &&
|
||||
new Date(assignment.round.votingStartAt) <= now &&
|
||||
new Date(assignment.round.votingEndAt) >= now
|
||||
|
||||
return (
|
||||
<TableRow key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm">
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}`}
|
||||
className="block group"
|
||||
>
|
||||
<p className="font-medium group-hover:text-primary group-hover:underline transition-colors">
|
||||
{truncate(assignment.project.title, 40)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{assignment.round.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{assignment.round.program.year} Edition
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.round.votingEndAt ? (
|
||||
<div>
|
||||
<span
|
||||
className={
|
||||
new Date(assignment.round.votingEndAt) < now
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{formatDate(assignment.round.votingEndAt)}
|
||||
</span>
|
||||
{(() => {
|
||||
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
|
||||
if (!urgency || isCompleted) return null
|
||||
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No deadline</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isCompleted ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
) : isDraft ? (
|
||||
<div className="space-y-1.5">
|
||||
<Badge variant="warning">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
{(() => {
|
||||
const progress = getCriteriaProgress(assignment.evaluation)
|
||||
if (!progress) return null
|
||||
const pct = Math.round((progress.completed / progress.total) * 100)
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={pct} className="h-1.5 w-16" />
|
||||
<span className="text-xs text-muted-foreground">{progress.completed}/{progress.total}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isCompleted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}/evaluation`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : isVotingOpen ? (
|
||||
<Button size="sm" asChild>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}/evaluate`}
|
||||
>
|
||||
{isDraft ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/projects/${assignment.project.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{assignments.map((assignment) => {
|
||||
const evaluation = assignment.evaluation
|
||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||
const isDraft = evaluation?.status === 'DRAFT'
|
||||
const isVotingOpen =
|
||||
assignment.round.status === 'ACTIVE' &&
|
||||
assignment.round.votingStartAt &&
|
||||
assignment.round.votingEndAt &&
|
||||
new Date(assignment.round.votingStartAt) <= now &&
|
||||
new Date(assignment.round.votingEndAt) >= now
|
||||
|
||||
return (
|
||||
<Card key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}`}
|
||||
className="space-y-1 group"
|
||||
>
|
||||
<CardTitle className="text-base group-hover:text-primary group-hover:underline transition-colors">
|
||||
{assignment.project.title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{assignment.project.teamName}
|
||||
</CardDescription>
|
||||
</Link>
|
||||
{isCompleted ? (
|
||||
<Badge variant="success">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Done
|
||||
</Badge>
|
||||
) : isDraft ? (
|
||||
<Badge variant="warning">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Draft
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Pending</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<span>{assignment.round.name}</span>
|
||||
</div>
|
||||
{assignment.round.votingEndAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deadline</span>
|
||||
<div className="text-right">
|
||||
<span>{formatDate(assignment.round.votingEndAt)}</span>
|
||||
{(() => {
|
||||
const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null)
|
||||
if (!urgency || isCompleted) return null
|
||||
return <p className={cn('text-xs mt-0.5', urgency.className)}>{urgency.label}</p>
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isDraft && (() => {
|
||||
const progress = getCriteriaProgress(assignment.evaluation)
|
||||
if (!progress) return null
|
||||
const pct = Math.round((progress.completed / progress.total) * 100)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="text-xs">{progress.completed}/{progress.total} criteria</span>
|
||||
</div>
|
||||
<Progress value={pct} className="h-1.5" />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="pt-2">
|
||||
{isCompleted ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}/evaluation`}
|
||||
>
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isVotingOpen ? (
|
||||
<Button size="sm" className="w-full" asChild>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}/evaluate`}
|
||||
>
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/jury/projects/${assignment.project.id}`}>
|
||||
View Project
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssignmentsSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JuryAssignmentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ round?: string }>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const roundId = params.round
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My Assignments</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Projects assigned to you for evaluation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<AssignmentsSkeleton />}>
|
||||
<AssignmentsContent roundId={roundId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
MapPin,
|
||||
Users,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Criterion = {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale?: string
|
||||
weight?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
type ComparisonItem = {
|
||||
project: Record<string, unknown>
|
||||
evaluation: Record<string, unknown> | null
|
||||
assignmentId: string
|
||||
}
|
||||
|
||||
type ComparisonData = {
|
||||
items: ComparisonItem[]
|
||||
criteria: Criterion[] | null
|
||||
scales: Record<string, { min: number; max: number }> | null
|
||||
}
|
||||
|
||||
function getScoreColor(score: number, max: number): string {
|
||||
const ratio = score / max
|
||||
if (ratio >= 0.8) return 'bg-green-500'
|
||||
if (ratio >= 0.6) return 'bg-emerald-400'
|
||||
if (ratio >= 0.4) return 'bg-amber-400'
|
||||
if (ratio >= 0.2) return 'bg-orange-400'
|
||||
return 'bg-red-400'
|
||||
}
|
||||
|
||||
function getScoreTextColor(score: number, max: number): string {
|
||||
const ratio = score / max
|
||||
if (ratio >= 0.8) return 'text-green-600 dark:text-green-400'
|
||||
if (ratio >= 0.6) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
function ScoreRing({ score, max }: { score: number; max: number }) {
|
||||
const pct = Math.round((score / max) * 100)
|
||||
const circumference = 2 * Math.PI * 36
|
||||
const offset = circumference - (pct / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg className="w-20 h-20 -rotate-90" viewBox="0 0 80 80">
|
||||
<circle
|
||||
cx="40" cy="40" r="36"
|
||||
className="stroke-muted"
|
||||
strokeWidth="6" fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="40" cy="40" r="36"
|
||||
className={cn(
|
||||
'transition-all duration-500',
|
||||
pct >= 80 ? 'stroke-green-500' :
|
||||
pct >= 60 ? 'stroke-emerald-400' :
|
||||
pct >= 40 ? 'stroke-amber-400' : 'stroke-red-400'
|
||||
)}
|
||||
strokeWidth="6" fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center">
|
||||
<span className="text-lg font-bold tabular-nums">{score}</span>
|
||||
<span className="text-[10px] text-muted-foreground">/{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreBar({ score, max, isHighest }: { score: number; max: number; isHighest: boolean }) {
|
||||
const pct = Math.round((score / max) * 100)
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', getScoreColor(score, max))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium tabular-nums w-8 text-right',
|
||||
isHighest && 'font-bold',
|
||||
getScoreTextColor(score, max)
|
||||
)}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompareProjectsPage() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [comparing, setComparing] = useState(false)
|
||||
|
||||
const { data: assignments, isLoading: loadingAssignments } =
|
||||
trpc.assignment.myAssignments.useQuery({})
|
||||
|
||||
const rounds = useMemo(() => {
|
||||
if (!assignments) return []
|
||||
const roundMap = new Map<string, { id: string; name: string }>()
|
||||
for (const a of assignments as Array<{ round: { id: string; name: string } }>) {
|
||||
if (a.round && !roundMap.has(a.round.id)) {
|
||||
roundMap.set(a.round.id, { id: a.round.id, name: String(a.round.name) })
|
||||
}
|
||||
}
|
||||
return Array.from(roundMap.values())
|
||||
}, [assignments])
|
||||
|
||||
const activeRoundId = selectedRoundId || (rounds.length > 0 ? rounds[0].id : '')
|
||||
|
||||
const roundProjects = useMemo(() => {
|
||||
if (!assignments || !activeRoundId) return []
|
||||
return (assignments as Array<{
|
||||
project: Record<string, unknown>
|
||||
round: { id: string; name: string }
|
||||
evaluation?: Record<string, unknown>
|
||||
}>)
|
||||
.filter((a) => a.round.id === activeRoundId)
|
||||
.map((a) => ({
|
||||
...a.project,
|
||||
roundName: a.round.name,
|
||||
evaluation: a.evaluation,
|
||||
}))
|
||||
}, [assignments, activeRoundId])
|
||||
|
||||
const { data: comparisonData, isLoading: loadingComparison } =
|
||||
trpc.evaluation.getMultipleForComparison.useQuery(
|
||||
{ projectIds: selectedIds, roundId: activeRoundId },
|
||||
{ enabled: comparing && selectedIds.length >= 2 && !!activeRoundId }
|
||||
)
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.includes(projectId)) {
|
||||
return prev.filter((id) => id !== projectId)
|
||||
}
|
||||
if (prev.length >= 3) return prev
|
||||
return [...prev, projectId]
|
||||
})
|
||||
setComparing(false)
|
||||
}
|
||||
|
||||
const handleCompare = () => {
|
||||
if (selectedIds.length >= 2) setComparing(true)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setComparing(false)
|
||||
setSelectedIds([])
|
||||
}
|
||||
|
||||
const handleRoundChange = (roundId: string) => {
|
||||
setSelectedRoundId(roundId)
|
||||
setSelectedIds([])
|
||||
setComparing(false)
|
||||
}
|
||||
|
||||
if (loadingAssignments) return <CompareSkeleton />
|
||||
|
||||
const data = comparisonData as ComparisonData | undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Compare Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Select 2-3 projects from the same round to compare side by side
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{comparing && (
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
{!comparing && (
|
||||
<Button onClick={handleCompare} disabled={selectedIds.length < 2}>
|
||||
<GitCompare className="mr-2 h-4 w-4" />
|
||||
Compare ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Round selector */}
|
||||
{rounds.length > 1 && !comparing && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">Round:</span>
|
||||
<Select value={activeRoundId} onValueChange={handleRoundChange}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project selector */}
|
||||
{!comparing && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{roundProjects.map((project: Record<string, unknown>) => {
|
||||
const projectId = project.id as string
|
||||
const isSelected = selectedIds.includes(projectId)
|
||||
const isDisabled = !isSelected && selectedIds.length >= 3
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={projectId}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: isDisabled
|
||||
? 'opacity-50'
|
||||
: 'hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => !isDisabled && toggleProject(projectId)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={() => toggleProject(projectId)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{String(project.title || 'Untitled')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{String(project.teamName || '')}
|
||||
</p>
|
||||
</div>
|
||||
{project.evaluation ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roundProjects.length === 0 && !loadingAssignments && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<GitCompare className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No projects assigned</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need at least 2 assigned projects to use the comparison feature.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Comparison view */}
|
||||
{comparing && loadingComparison && <CompareSkeleton />}
|
||||
|
||||
{comparing && data && (
|
||||
<div className="space-y-6">
|
||||
{/* Project summary cards */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 grid-cols-1',
|
||||
data.items.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}
|
||||
>
|
||||
{data.items.map((item) => (
|
||||
<ComparisonCard
|
||||
key={String(item.project.id)}
|
||||
project={item.project}
|
||||
evaluation={item.evaluation}
|
||||
criteria={data.criteria}
|
||||
scales={data.scales}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Side-by-side criterion comparison table */}
|
||||
{data.criteria && data.criteria.filter((c) => c.type !== 'section_header').length > 0 && (
|
||||
<CriterionComparisonTable
|
||||
items={data.items}
|
||||
criteria={data.criteria}
|
||||
scales={data.scales}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Divergence Summary */}
|
||||
{data.criteria && (() => {
|
||||
const scCriteria = data.criteria.filter((c) => c.type !== 'section_header')
|
||||
const getMaxForCriterion = (criterion: Criterion) => {
|
||||
if (criterion.scale && data.scales && data.scales[criterion.scale]) return data.scales[criterion.scale].max
|
||||
return 10
|
||||
}
|
||||
const getScoreForItem = (item: ComparisonItem, criterionId: string): number | null => {
|
||||
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
|
||||
if (!scores) return null
|
||||
const val = scores[criterionId]
|
||||
if (val == null) return null
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? null : num
|
||||
}
|
||||
const divergentCount = scCriteria.filter(criterion => {
|
||||
const scores = data.items.map(item => getScoreForItem(item, criterion.id)).filter((s): s is number => s !== null)
|
||||
if (scores.length < 2) return false
|
||||
const max = Math.max(...scores)
|
||||
const min = Math.min(...scores)
|
||||
const range = getMaxForCriterion(criterion)
|
||||
return range > 0 && (max - min) / range >= 0.4
|
||||
}).length
|
||||
|
||||
if (divergentCount === 0) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-800 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{divergentCount} criterion{divergentCount > 1 ? 'a' : ''} with significant score divergence ({'>'}40% range difference)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonCard({
|
||||
project,
|
||||
evaluation,
|
||||
criteria,
|
||||
scales,
|
||||
}: {
|
||||
project: Record<string, unknown>
|
||||
evaluation: Record<string, unknown> | null
|
||||
criteria: Criterion[] | null
|
||||
scales: Record<string, { min: number; max: number }> | null
|
||||
}) {
|
||||
const tags = Array.isArray(project.tags) ? project.tags : []
|
||||
const files = Array.isArray(project.files) ? project.files : []
|
||||
const scores = (evaluation?.criterionScoresJson || evaluation?.scores) as Record<string, unknown> | undefined
|
||||
const globalScore = evaluation?.globalScore as number | null | undefined
|
||||
const binaryDecision = evaluation?.binaryDecision as boolean | null | undefined
|
||||
|
||||
// Build a criterion label lookup
|
||||
const criterionLabels = useMemo(() => {
|
||||
const map: Record<string, { label: string; scale?: string }> = {}
|
||||
if (criteria) {
|
||||
for (const c of criteria) {
|
||||
map[c.id] = { label: c.label, scale: c.scale }
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [criteria])
|
||||
|
||||
const getMax = (criterionId: string) => {
|
||||
const scale = criterionLabels[criterionId]?.scale
|
||||
if (scale && scales && scales[scale]) return scales[scale].max
|
||||
return 10
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg">{String(project.title || 'Untitled')}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{String(project.teamName || 'N/A')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* Global score ring */}
|
||||
{evaluation && globalScore != null && (
|
||||
<ScoreRing score={globalScore} max={10} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Binary decision */}
|
||||
{evaluation && binaryDecision != null && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium',
|
||||
binaryDecision
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-950/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
|
||||
)}>
|
||||
{binaryDecision ? (
|
||||
<><ThumbsUp className="h-4 w-4" /> Recommended to advance</>
|
||||
) : (
|
||||
<><ThumbsDown className="h-4 w-4" /> Not recommended</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country */}
|
||||
{!!project.country && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
{String(project.country)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{!!project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">{String(project.description)}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag: unknown, i: number) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">
|
||||
{String(tag)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{files.length} file{files.length > 1 ? 's' : ''} attached
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Evaluation scores with bars */}
|
||||
{evaluation && scores && typeof scores === 'object' ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Criterion Scores</p>
|
||||
{Object.entries(scores).map(([criterionId, score]) => {
|
||||
const numScore = Number(score)
|
||||
if (isNaN(numScore)) return null
|
||||
const label = criterionLabels[criterionId]?.label || criterionId
|
||||
const max = getMax(criterionId)
|
||||
return (
|
||||
<div key={criterionId} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground truncate">{label}</span>
|
||||
</div>
|
||||
<ScoreBar score={numScore} max={max} isHighest={false} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : evaluation ? (
|
||||
<Badge variant="outline">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Not yet evaluated
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CriterionComparisonTable({
|
||||
items,
|
||||
criteria,
|
||||
scales,
|
||||
}: {
|
||||
items: ComparisonItem[]
|
||||
criteria: Criterion[]
|
||||
scales: Record<string, { min: number; max: number }> | null
|
||||
}) {
|
||||
const scoreableCriteria = criteria.filter((c) => c.type !== 'section_header')
|
||||
|
||||
const getMax = (criterion: Criterion) => {
|
||||
if (criterion.scale && scales && scales[criterion.scale]) return scales[criterion.scale].max
|
||||
return 10
|
||||
}
|
||||
|
||||
// Build score matrix
|
||||
const getScore = (item: ComparisonItem, criterionId: string): number | null => {
|
||||
const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record<string, unknown> | undefined
|
||||
if (!scores) return null
|
||||
const val = scores[criterionId]
|
||||
if (val == null) return null
|
||||
const num = Number(val)
|
||||
return isNaN(num) ? null : num
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criterion-by-Criterion Comparison</CardTitle>
|
||||
<CardDescription>
|
||||
Scores compared side by side. Highest score per criterion is highlighted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px]">Criterion</TableHead>
|
||||
{items.map((item) => (
|
||||
<TableHead key={item.assignmentId} className="text-center min-w-[150px]">
|
||||
{String((item.project as Record<string, unknown>).title || 'Untitled')}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scoreableCriteria.map((criterion) => {
|
||||
const max = getMax(criterion)
|
||||
const itemScores = items.map((item) => getScore(item, criterion.id))
|
||||
const validScores = itemScores.filter((s): s is number => s !== null)
|
||||
const highestScore = validScores.length > 0 ? Math.max(...validScores) : null
|
||||
const minScore = validScores.length > 0 ? Math.min(...validScores) : null
|
||||
const divergence = highestScore !== null && minScore !== null ? highestScore - minScore : 0
|
||||
const maxPossibleDivergence = max
|
||||
const isDivergent = validScores.length >= 2 && maxPossibleDivergence > 0 && (divergence / maxPossibleDivergence) >= 0.4
|
||||
|
||||
return (
|
||||
<TableRow key={criterion.id} className={cn(isDivergent && 'bg-amber-50 dark:bg-amber-950/20')}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<span className="text-sm">{criterion.label}</span>
|
||||
{criterion.weight && criterion.weight > 1 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(x{criterion.weight})
|
||||
</span>
|
||||
)}
|
||||
{isDivergent && <Badge variant="outline" className="text-[10px] ml-1.5 text-amber-600 border-amber-300">Divergent</Badge>}
|
||||
</div>
|
||||
</TableCell>
|
||||
{items.map((item, idx) => {
|
||||
const score = itemScores[idx]
|
||||
const isHighest = score !== null && score === highestScore && validScores.filter((s) => s === highestScore).length < validScores.length
|
||||
return (
|
||||
<TableCell key={item.assignmentId} className="text-center">
|
||||
{score !== null ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className={cn(
|
||||
'text-sm font-medium tabular-nums',
|
||||
isHighest && 'text-green-600 dark:text-green-400 font-bold',
|
||||
getScoreTextColor(score, max)
|
||||
)}>
|
||||
{score}/{max}
|
||||
</span>
|
||||
<div className="w-full max-w-[80px] h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full', getScoreColor(score, max))}
|
||||
style={{ width: `${(score / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="border-t-2 font-semibold">
|
||||
<TableCell>Overall Score</TableCell>
|
||||
{items.map((item) => {
|
||||
const globalScore = item.evaluation?.globalScore as number | null | undefined
|
||||
return (
|
||||
<TableCell key={item.assignmentId} className="text-center">
|
||||
{globalScore != null ? (
|
||||
<span className={cn(
|
||||
'text-base font-bold tabular-nums',
|
||||
getScoreTextColor(globalScore, 10)
|
||||
)}>
|
||||
{globalScore}/10
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Binary decision row */}
|
||||
<TableRow>
|
||||
<TableCell>Advance Decision</TableCell>
|
||||
{items.map((item) => {
|
||||
const decision = item.evaluation?.binaryDecision as boolean | null | undefined
|
||||
return (
|
||||
<TableCell key={item.assignmentId} className="text-center">
|
||||
{decision != null ? (
|
||||
decision ? (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> Yes
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<XCircle className="h-3 w-3" /> No
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CompareSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { toast } from 'sonner'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
|
||||
{ sessionId },
|
||||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
const votingMode = data?.session.votingMode || 'simple'
|
||||
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const { isConnected } = useLiveVotingSSE(sessionId, {
|
||||
onSessionStatus,
|
||||
onProjectChange,
|
||||
})
|
||||
|
||||
// Vote mutation
|
||||
const vote = trpc.liveVoting.vote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote recorded')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Update countdown
|
||||
useEffect(() => {
|
||||
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
|
||||
setCountdown(data.timeRemaining)
|
||||
} else {
|
||||
setCountdown(null)
|
||||
}
|
||||
}, [data?.timeRemaining])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev === null || prev <= 0) return 0
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown])
|
||||
|
||||
// Set selected score from existing vote
|
||||
useEffect(() => {
|
||||
if (data?.userVote) {
|
||||
setSelectedScore(data.userVote.score)
|
||||
// Restore criterion scores if available
|
||||
if (data.userVote.criterionScoresJson) {
|
||||
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
|
||||
}
|
||||
} else {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
}
|
||||
}, [data?.userVote, data?.currentProject?.id])
|
||||
|
||||
// Initialize criterion scores with mid-values when criteria change
|
||||
useEffect(() => {
|
||||
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
|
||||
const initial: Record<string, number> = {}
|
||||
for (const c of criteria) {
|
||||
initial[c.id] = Math.ceil(c.scale / 2)
|
||||
}
|
||||
setCriterionScores(initial)
|
||||
}
|
||||
}, [votingMode, criteria, criterionScores])
|
||||
|
||||
const handleSimpleVote = (score: number) => {
|
||||
if (!data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
vote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCriteriaVote = () => {
|
||||
if (!data?.currentProject) return
|
||||
|
||||
// Compute a rough overall score for the `score` field
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
|
||||
vote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score: computedScore,
|
||||
criterionScores,
|
||||
})
|
||||
}
|
||||
|
||||
const computeWeightedScore = (): number => {
|
||||
if (criteria.length === 0) return 0
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <JuryVotingSkeleton />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Session Not Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
This voting session does not exist or has ended.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isVoting = data.session.status === 'IN_PROGRESS'
|
||||
const hasVoted = !!data.userVote
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
<CardTitle>Live Voting</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data.round.program.name} - {data.round.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isVoting && data.currentProject ? (
|
||||
<>
|
||||
{/* Current project */}
|
||||
<div className="text-center space-y-2">
|
||||
<Badge variant="default" className="mb-2">
|
||||
Now Presenting
|
||||
</Badge>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{data.currentProject.title}
|
||||
</h2>
|
||||
{data.currentProject.teamName && (
|
||||
<p className="text-muted-foreground">
|
||||
{data.currentProject.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-primary mb-2">
|
||||
{countdown !== null ? `${countdown}s` : '--'}
|
||||
</div>
|
||||
<Progress
|
||||
value={countdown !== null ? (countdown / 30) * 100 : 0}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Time remaining to vote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Voting UI - Simple mode */}
|
||||
{votingMode === 'simple' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleSimpleVote(score)}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voting UI - Criteria mode */}
|
||||
{votingMode === 'criteria' && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-center">Score Each Criterion</p>
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.label}</p>
|
||||
{c.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{c.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
|
||||
{criterionScores[c.id] || 1}/{c.scale}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={c.scale}
|
||||
step={1}
|
||||
value={[criterionScores[c.id] || 1]}
|
||||
onValueChange={([val]) => {
|
||||
setCriterionScores((prev) => ({
|
||||
...prev,
|
||||
[c.id]: val,
|
||||
}))
|
||||
}}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Computed weighted score */}
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<p className="text-sm font-medium">Weighted Score</p>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{computeWeightedScore().toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCriteriaVote}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVoted && (
|
||||
<Alert className="bg-green-500/10 border-green-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
Your vote has been recorded! You can change it before time runs out.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Waiting state */
|
||||
<div className="text-center py-12">
|
||||
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Waiting for Next Project
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{data.session.status === 'COMPLETED'
|
||||
? 'The voting session has ended. Thank you for participating!'
|
||||
: 'The admin will start voting for the next project.'}
|
||||
</p>
|
||||
{data.session.status !== 'COMPLETED' && (
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
This page will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile-friendly footer */}
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-red-400" />
|
||||
)}
|
||||
<p className="text-white/60 text-sm">
|
||||
MOPC Live Voting {isConnected ? '- Connected' : '- Reconnecting...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JuryVotingSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<Skeleton className="h-6 w-32 mx-auto" />
|
||||
<Skeleton className="h-4 w-48 mx-auto mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JuryLiveVotingPage({ params }: PageProps) {
|
||||
const { sessionId } = use(params)
|
||||
|
||||
return <JuryVotingContent sessionId={sessionId} />
|
||||
}
|
||||
@@ -60,17 +60,25 @@ async function JuryDashboardContent() {
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
stage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
votingStartAt: true,
|
||||
votingEndAt: true,
|
||||
program: {
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
track: {
|
||||
select: {
|
||||
name: true,
|
||||
year: true,
|
||||
pipeline: {
|
||||
select: {
|
||||
program: {
|
||||
select: {
|
||||
name: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,7 +96,7 @@ async function JuryDashboardContent() {
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ round: { votingEndAt: 'asc' } },
|
||||
{ stage: { windowCloseAt: 'asc' } },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
}),
|
||||
@@ -98,7 +106,7 @@ async function JuryDashboardContent() {
|
||||
extendedUntil: { gte: new Date() },
|
||||
},
|
||||
select: {
|
||||
roundId: true,
|
||||
stageId: true,
|
||||
extendedUntil: true,
|
||||
},
|
||||
}),
|
||||
@@ -118,49 +126,49 @@ async function JuryDashboardContent() {
|
||||
const completionRate =
|
||||
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
|
||||
|
||||
// Group assignments by round
|
||||
const assignmentsByRound = assignments.reduce(
|
||||
// Group assignments by stage
|
||||
const assignmentsByStage = assignments.reduce(
|
||||
(acc, assignment) => {
|
||||
const roundId = assignment.round.id
|
||||
if (!acc[roundId]) {
|
||||
acc[roundId] = {
|
||||
round: assignment.round,
|
||||
const stageId = assignment.stage.id
|
||||
if (!acc[stageId]) {
|
||||
acc[stageId] = {
|
||||
stage: assignment.stage,
|
||||
assignments: [],
|
||||
}
|
||||
}
|
||||
acc[roundId].assignments.push(assignment)
|
||||
acc[stageId].assignments.push(assignment)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
|
||||
{} as Record<string, { stage: (typeof assignments)[0]['stage']; assignments: typeof assignments }>
|
||||
)
|
||||
|
||||
const graceByRound = new Map<string, Date>()
|
||||
const graceByStage = new Map<string, Date>()
|
||||
for (const gp of gracePeriods) {
|
||||
const existing = graceByRound.get(gp.roundId)
|
||||
const existing = graceByStage.get(gp.stageId)
|
||||
if (!existing || gp.extendedUntil > existing) {
|
||||
graceByRound.set(gp.roundId, gp.extendedUntil)
|
||||
graceByStage.set(gp.stageId, gp.extendedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
// Active rounds (voting window open)
|
||||
// Active stages (voting window open)
|
||||
const now = new Date()
|
||||
const activeRounds = Object.values(assignmentsByRound).filter(
|
||||
({ round }) =>
|
||||
round.status === 'ACTIVE' &&
|
||||
round.votingStartAt &&
|
||||
round.votingEndAt &&
|
||||
new Date(round.votingStartAt) <= now &&
|
||||
new Date(round.votingEndAt) >= now
|
||||
const activeStages = Object.values(assignmentsByStage).filter(
|
||||
({ stage }) =>
|
||||
stage.status === 'STAGE_ACTIVE' &&
|
||||
stage.windowOpenAt &&
|
||||
stage.windowCloseAt &&
|
||||
new Date(stage.windowOpenAt) <= now &&
|
||||
new Date(stage.windowCloseAt) >= now
|
||||
)
|
||||
|
||||
// Find next unevaluated assignment in an active round
|
||||
// Find next unevaluated assignment in an active stage
|
||||
const nextUnevaluated = assignments.find((a) => {
|
||||
const isActive =
|
||||
a.round.status === 'ACTIVE' &&
|
||||
a.round.votingStartAt &&
|
||||
a.round.votingEndAt &&
|
||||
new Date(a.round.votingStartAt) <= now &&
|
||||
new Date(a.round.votingEndAt) >= now
|
||||
a.stage.status === 'STAGE_ACTIVE' &&
|
||||
a.stage.windowOpenAt &&
|
||||
a.stage.windowCloseAt &&
|
||||
new Date(a.stage.windowOpenAt) <= now &&
|
||||
new Date(a.stage.windowCloseAt) >= now
|
||||
const isIncomplete = !a.evaluation || a.evaluation.status === 'NOT_STARTED' || a.evaluation.status === 'DRAFT'
|
||||
return isActive && isIncomplete
|
||||
})
|
||||
@@ -168,14 +176,14 @@ async function JuryDashboardContent() {
|
||||
// Recent assignments for the quick list (latest 5)
|
||||
const recentAssignments = assignments.slice(0, 6)
|
||||
|
||||
// Get active round remaining count
|
||||
// Get active stage remaining count
|
||||
const activeRemaining = assignments.filter((a) => {
|
||||
const isActive =
|
||||
a.round.status === 'ACTIVE' &&
|
||||
a.round.votingStartAt &&
|
||||
a.round.votingEndAt &&
|
||||
new Date(a.round.votingStartAt) <= now &&
|
||||
new Date(a.round.votingEndAt) >= now
|
||||
a.stage.status === 'STAGE_ACTIVE' &&
|
||||
a.stage.windowOpenAt &&
|
||||
a.stage.windowCloseAt &&
|
||||
new Date(a.stage.windowOpenAt) <= now &&
|
||||
new Date(a.stage.windowCloseAt) >= now
|
||||
const isIncomplete = !a.evaluation || a.evaluation.status !== 'SUBMITTED'
|
||||
return isActive && isIncomplete
|
||||
}).length
|
||||
@@ -233,7 +241,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
|
||||
<Link
|
||||
href="/jury/assignments"
|
||||
href="/jury/stages"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
>
|
||||
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
||||
@@ -245,7 +253,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/jury/compare"
|
||||
href="/jury/stages"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
||||
@@ -285,7 +293,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild size="lg" className="bg-brand-blue hover:bg-brand-blue-light shadow-md">
|
||||
<Link href={`/jury/projects/${nextUnevaluated.project.id}/evaluate`}>
|
||||
<Link href={`/jury/stages/${nextUnevaluated.stage.id}/projects/${nextUnevaluated.project.id}/evaluate`}>
|
||||
{nextUnevaluated.evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@@ -355,7 +363,7 @@ async function JuryDashboardContent() {
|
||||
<CardTitle className="text-lg">My Assignments</CardTitle>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild className="text-brand-teal hover:text-brand-blue">
|
||||
<Link href="/jury/assignments">
|
||||
<Link href="/jury/stages">
|
||||
View all
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
@@ -370,11 +378,11 @@ async function JuryDashboardContent() {
|
||||
const isCompleted = evaluation?.status === 'SUBMITTED'
|
||||
const isDraft = evaluation?.status === 'DRAFT'
|
||||
const isVotingOpen =
|
||||
assignment.round.status === 'ACTIVE' &&
|
||||
assignment.round.votingStartAt &&
|
||||
assignment.round.votingEndAt &&
|
||||
new Date(assignment.round.votingStartAt) <= now &&
|
||||
new Date(assignment.round.votingEndAt) >= now
|
||||
assignment.stage.status === 'STAGE_ACTIVE' &&
|
||||
assignment.stage.windowOpenAt &&
|
||||
assignment.stage.windowCloseAt &&
|
||||
new Date(assignment.stage.windowOpenAt) <= now &&
|
||||
new Date(assignment.stage.windowCloseAt) >= now
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -386,7 +394,7 @@ async function JuryDashboardContent() {
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}`}
|
||||
href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}
|
||||
className="flex-1 min-w-0 group"
|
||||
>
|
||||
<p className="text-sm font-medium truncate group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
|
||||
@@ -397,7 +405,7 @@ async function JuryDashboardContent() {
|
||||
{assignment.project.teamName}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-brand-blue/5 text-brand-blue/80 dark:bg-brand-teal/10 dark:text-brand-teal/80 border-0">
|
||||
{assignment.round.name}
|
||||
{assignment.stage.name}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -417,19 +425,19 @@ async function JuryDashboardContent() {
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
|
||||
<Link href={`/jury/projects/${assignment.project.id}/evaluation`}>
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluation`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
) : isVotingOpen ? (
|
||||
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||
<Link href={`/jury/projects/${assignment.project.id}/evaluate`}>
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}/evaluate`}>
|
||||
{isDraft ? 'Continue' : 'Evaluate'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 px-2">
|
||||
<Link href={`/jury/projects/${assignment.project.id}`}>
|
||||
<Link href={`/jury/stages/${assignment.stage.id}/projects/${assignment.project.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -470,7 +478,7 @@ async function JuryDashboardContent() {
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/jury/assignments"
|
||||
href="/jury/stages"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||
>
|
||||
<div className="rounded-xl bg-blue-50 p-3 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40 dark:group-hover:bg-blue-950/60">
|
||||
@@ -482,7 +490,7 @@ async function JuryDashboardContent() {
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/jury/compare"
|
||||
href="/jury/stages"
|
||||
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded-xl bg-teal-50 p-3 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40 dark:group-hover:bg-teal-950/60">
|
||||
@@ -501,8 +509,8 @@ async function JuryDashboardContent() {
|
||||
|
||||
{/* Right column */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
{/* Active Rounds */}
|
||||
{activeRounds.length > 0 && (
|
||||
{/* Active Stages */}
|
||||
{activeStages.length > 0 && (
|
||||
<AnimatedCard index={8}>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
@@ -512,28 +520,29 @@ async function JuryDashboardContent() {
|
||||
<Waves className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
|
||||
<CardTitle className="text-lg">Active Voting Stages</CardTitle>
|
||||
<CardDescription className="mt-0.5">
|
||||
Rounds currently open for evaluation
|
||||
Stages currently open for evaluation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeRounds.map(({ round, assignments: roundAssignments }) => {
|
||||
const roundCompleted = roundAssignments.filter(
|
||||
{activeStages.map(({ stage, assignments: stageAssignments }) => {
|
||||
const stageCompleted = stageAssignments.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length
|
||||
const roundTotal = roundAssignments.length
|
||||
const roundProgress =
|
||||
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
|
||||
const isAlmostDone = roundProgress >= 80
|
||||
const deadline = graceByRound.get(round.id) ?? (round.votingEndAt ? new Date(round.votingEndAt) : null)
|
||||
const stageTotal = stageAssignments.length
|
||||
const stageProgress =
|
||||
stageTotal > 0 ? (stageCompleted / stageTotal) * 100 : 0
|
||||
const isAlmostDone = stageProgress >= 80
|
||||
const deadline = graceByStage.get(stage.id) ?? (stage.windowCloseAt ? new Date(stage.windowCloseAt) : null)
|
||||
const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
|
||||
const program = stage.track.pipeline.program
|
||||
|
||||
return (
|
||||
<div
|
||||
key={round.id}
|
||||
key={stage.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isUrgent
|
||||
@@ -543,9 +552,9 @@ async function JuryDashboardContent() {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{round.name}</h3>
|
||||
<h3 className="font-semibold text-brand-blue dark:text-brand-teal">{stage.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{round.program.name} · {round.program.year}
|
||||
{program.name} · {program.year}
|
||||
</p>
|
||||
</div>
|
||||
{isAlmostDone ? (
|
||||
@@ -559,13 +568,13 @@ async function JuryDashboardContent() {
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{roundCompleted}/{roundTotal}
|
||||
{stageCompleted}/{stageTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2.5 w-full overflow-hidden rounded-full bg-muted/60">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
||||
style={{ width: `${roundProgress}%` }}
|
||||
style={{ width: `${stageProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,16 +585,16 @@ async function JuryDashboardContent() {
|
||||
deadline={deadline}
|
||||
label="Deadline:"
|
||||
/>
|
||||
{round.votingEndAt && (
|
||||
{stage.windowCloseAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDateOnly(round.votingEndAt)})
|
||||
({formatDateOnly(stage.windowCloseAt)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild size="sm" className="w-full bg-brand-blue hover:bg-brand-blue-light shadow-sm">
|
||||
<Link href={`/jury/assignments?round=${round.id}`}>
|
||||
<Link href={`/jury/stages/${stage.id}/assignments`}>
|
||||
View Assignments
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
@@ -598,15 +607,15 @@ async function JuryDashboardContent() {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* No active rounds */}
|
||||
{activeRounds.length === 0 && (
|
||||
{/* No active stages */}
|
||||
{activeStages.length === 0 && (
|
||||
<AnimatedCard index={8}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
|
||||
<Clock className="h-6 w-6 text-brand-teal/70" />
|
||||
</div>
|
||||
<p className="font-semibold text-sm">No active voting rounds</p>
|
||||
<p className="font-semibold text-sm">No active voting stages</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
|
||||
Check back later when a voting window opens
|
||||
</p>
|
||||
@@ -615,8 +624,8 @@ async function JuryDashboardContent() {
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Completion Summary by Round */}
|
||||
{Object.keys(assignmentsByRound).length > 0 && (
|
||||
{/* Completion Summary by Stage */}
|
||||
{Object.keys(assignmentsByStage).length > 0 && (
|
||||
<AnimatedCard index={9}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -624,18 +633,18 @@ async function JuryDashboardContent() {
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||
<CardTitle className="text-lg">Stage Summary</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Object.values(assignmentsByRound).map(({ round, assignments: roundAssignments }) => {
|
||||
const done = roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
||||
const total = roundAssignments.length
|
||||
{Object.values(assignmentsByStage).map(({ stage, assignments: stageAssignments }) => {
|
||||
const done = stageAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length
|
||||
const total = stageAssignments.length
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
return (
|
||||
<div key={round.id} className="space-y-2">
|
||||
<div key={stage.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium truncate">{round.name}</span>
|
||||
<span className="font-medium truncate">{stage.name}</span>
|
||||
<div className="flex items-baseline gap-1 shrink-0 ml-2">
|
||||
<span className="font-bold tabular-nums text-brand-blue dark:text-brand-teal">{pct}%</span>
|
||||
<span className="text-xs text-muted-foreground">({done}/{total})</span>
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Loader2,
|
||||
Lock,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate, cn, getInitials } from '@/lib/utils'
|
||||
|
||||
export default function DiscussionPage() {
|
||||
const params = useParams()
|
||||
const searchParams = useSearchParams()
|
||||
const projectId = params.id as string
|
||||
const roundId = searchParams.get('roundId') || ''
|
||||
|
||||
const [commentText, setCommentText] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Fetch peer summary
|
||||
const { data: peerSummary, isLoading: loadingSummary } =
|
||||
trpc.evaluation.getPeerSummary.useQuery(
|
||||
{ projectId, roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Fetch discussion thread
|
||||
const { data: discussion, isLoading: loadingDiscussion } =
|
||||
trpc.evaluation.getDiscussion.useQuery(
|
||||
{ projectId, roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Add comment mutation
|
||||
const addCommentMutation = trpc.evaluation.addComment.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.evaluation.getDiscussion.invalidate({ projectId, roundId })
|
||||
toast.success('Comment added')
|
||||
setCommentText('')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const handleSubmitComment = () => {
|
||||
if (!commentText.trim()) {
|
||||
toast.error('Please enter a comment')
|
||||
return
|
||||
}
|
||||
addCommentMutation.mutate({
|
||||
projectId,
|
||||
roundId,
|
||||
content: commentText.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const isLoading = loadingSummary || loadingDiscussion
|
||||
|
||||
if (!roundId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No round specified</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please access the discussion from your assignments page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DiscussionSkeleton />
|
||||
}
|
||||
|
||||
// Parse peer summary data
|
||||
const summary = peerSummary as Record<string, unknown> | undefined
|
||||
const averageScore = summary ? Number(summary.averageScore || 0) : 0
|
||||
const scoreRange = summary?.scoreRange as { min: number; max: number } | undefined
|
||||
const evaluationCount = summary ? Number(summary.evaluationCount || 0) : 0
|
||||
const individualScores = (summary?.scores || summary?.individualScores) as
|
||||
| Array<number>
|
||||
| undefined
|
||||
|
||||
// Parse discussion data
|
||||
const discussionData = discussion as Record<string, unknown> | undefined
|
||||
const comments = (discussionData?.comments || []) as Array<{
|
||||
id: string
|
||||
user: { id: string; name: string | null; email: string }
|
||||
content: string
|
||||
createdAt: string
|
||||
}>
|
||||
const discussionStatus = String(discussionData?.status || 'OPEN')
|
||||
const isClosed = discussionStatus === 'CLOSED'
|
||||
const closedAt = discussionData?.closedAt as string | undefined
|
||||
const closedBy = discussionData?.closedBy as Record<string, unknown> | undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Project Discussion
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Peer review discussion and anonymized score summary
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Peer Summary Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Peer Summary
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Anonymized scoring overview across all evaluations
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-2xl font-bold">{averageScore.toFixed(1)}</p>
|
||||
<p className="text-xs text-muted-foreground">Average Score</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-2xl font-bold">
|
||||
{scoreRange
|
||||
? `${scoreRange.min.toFixed(1)} - ${scoreRange.max.toFixed(1)}`
|
||||
: '--'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Score Range</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-2xl font-bold">{evaluationCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymized score bars */}
|
||||
{individualScores && individualScores.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Anonymized Individual Scores</p>
|
||||
<div className="flex items-end gap-2 h-24">
|
||||
{individualScores.map((score, i) => {
|
||||
const maxPossible = scoreRange?.max || 10
|
||||
const height =
|
||||
maxPossible > 0
|
||||
? Math.max((score / maxPossible) * 100, 4)
|
||||
: 4
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<span className="text-[10px] font-medium tabular-nums">
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full rounded-t transition-all',
|
||||
score >= averageScore
|
||||
? 'bg-primary/60'
|
||||
: 'bg-muted-foreground/30'
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discussion Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Discussion
|
||||
</CardTitle>
|
||||
{isClosed && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
Closed
|
||||
{closedAt && (
|
||||
<span className="ml-1">- {formatDate(closedAt)}</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{isClosed
|
||||
? 'This discussion has been closed.'
|
||||
: 'Share your thoughts with fellow jurors about this project.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Comments */}
|
||||
{comments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{comment.user?.name
|
||||
? getInitials(comment.user.name)
|
||||
: <User className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{comment.user?.name || 'Anonymous Juror'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-1 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<MessageSquare className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No comments yet. Be the first to start the discussion.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment input */}
|
||||
{!isClosed ? (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Textarea
|
||||
placeholder="Write your comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmitComment}
|
||||
disabled={
|
||||
addCommentMutation.isPending || !commentText.trim()
|
||||
}
|
||||
>
|
||||
{addCommentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Post Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
This discussion is closed and no longer accepts new comments.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscussionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-40 mt-2" />
|
||||
</div>
|
||||
|
||||
{/* Peer summary skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Discussion skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
|
||||
import { isFuture, isPast } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Define the criterion type for the evaluation form
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
|
||||
scale?: number
|
||||
weight?: number
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
trueLabel?: string
|
||||
falseLabel?: string
|
||||
condition?: {
|
||||
criterionId: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan'
|
||||
value: number | string | boolean
|
||||
}
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
select: { name: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: true,
|
||||
_count: {
|
||||
select: { files: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium text-destructive">Access Denied</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You are not assigned to evaluate this project
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const round = assignment.round
|
||||
const now = new Date()
|
||||
|
||||
// Check voting window
|
||||
const isVotingOpen =
|
||||
round.status === 'ACTIVE' &&
|
||||
round.votingStartAt &&
|
||||
round.votingEndAt &&
|
||||
new Date(round.votingStartAt) <= now &&
|
||||
new Date(round.votingEndAt) >= now
|
||||
|
||||
const isVotingUpcoming =
|
||||
round.votingStartAt && isFuture(new Date(round.votingStartAt))
|
||||
|
||||
const isVotingClosed = round.votingEndAt && isPast(new Date(round.votingEndAt))
|
||||
|
||||
// Check for grace period
|
||||
const gracePeriod = await prisma.gracePeriod.findFirst({
|
||||
where: {
|
||||
roundId: round.id,
|
||||
userId,
|
||||
OR: [{ projectId: null }, { projectId }],
|
||||
extendedUntil: { gte: now },
|
||||
},
|
||||
})
|
||||
|
||||
const hasGracePeriod = !!gracePeriod
|
||||
const effectiveVotingOpen = isVotingOpen || hasGracePeriod
|
||||
|
||||
// Check if already submitted
|
||||
const evaluation = assignment.evaluation
|
||||
const isSubmitted =
|
||||
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
||||
|
||||
if (isSubmitted) {
|
||||
redirect(`/jury/projects/${projectId}/evaluation`)
|
||||
}
|
||||
|
||||
// Check COI status
|
||||
const coiRecord = await prisma.conflictOfInterest.findUnique({
|
||||
where: { assignmentId: assignment.id },
|
||||
})
|
||||
const coiStatus = coiRecord
|
||||
? { hasConflict: coiRecord.hasConflict, declared: true }
|
||||
: { hasConflict: false, declared: false }
|
||||
|
||||
// Get evaluation form criteria
|
||||
const evaluationForm = round.evaluationForms[0]
|
||||
if (!evaluationForm) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/jury/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-amber-500/50" />
|
||||
<p className="mt-2 font-medium">Evaluation Form Not Available</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The evaluation criteria for this round have not been configured yet.
|
||||
Please check back later.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse criteria from JSON
|
||||
const criteria: Criterion[] = (evaluationForm.criteriaJson as unknown as Criterion[]) || []
|
||||
|
||||
// Handle voting not open
|
||||
if (!effectiveVotingOpen) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/jury/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Clock className="h-12 w-12 text-amber-500/50" />
|
||||
<p className="mt-2 font-medium">
|
||||
{isVotingUpcoming ? 'Voting Not Yet Open' : 'Voting Period Closed'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isVotingUpcoming
|
||||
? 'The voting window for this round has not started yet.'
|
||||
: 'The voting window for this round has ended.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button and project summary */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div>
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/jury/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{round.program.name}</span>
|
||||
<span>/</span>
|
||||
<span>{round.name}</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">Evaluate: {project.title}</h1>
|
||||
{project.teamName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{project.teamName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick file access */}
|
||||
{project.files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/projects/${projectId}`}>View Files</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grace period notice */}
|
||||
{hasGracePeriod && gracePeriod && (
|
||||
<Card className="border-amber-500 bg-amber-500/5">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
You have a grace period extension until{' '}
|
||||
{new Date(gracePeriod.extendedUntil).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project Files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={project.id}
|
||||
roundId={round.id}
|
||||
fileCount={project._count?.files || 0}
|
||||
/>
|
||||
|
||||
{/* Evaluation Form with COI Gate */}
|
||||
<EvaluationFormWithCOI
|
||||
assignmentId={assignment.id}
|
||||
evaluationId={evaluation?.id || null}
|
||||
projectTitle={project.title}
|
||||
criteria={criteria}
|
||||
initialData={
|
||||
evaluation
|
||||
? {
|
||||
criterionScoresJson: evaluation.criterionScoresJson as Record<
|
||||
string,
|
||||
number | string | boolean
|
||||
> | null,
|
||||
globalScore: evaluation.globalScore,
|
||||
binaryDecision: evaluation.binaryDecision,
|
||||
feedbackText: evaluation.feedbackText,
|
||||
status: evaluation.status,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isVotingOpen={effectiveVotingOpen}
|
||||
deadline={round.votingEndAt}
|
||||
coiStatus={coiStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EvaluateSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-6 w-80" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function EvaluatePage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
|
||||
return (
|
||||
<Suspense fallback={<EvaluateSkeleton />}>
|
||||
<EvaluateContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Calendar,
|
||||
Users,
|
||||
Star,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number
|
||||
weight?: number
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
async function EvaluationContent({ projectId }: { projectId: string }) {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: {
|
||||
include: {
|
||||
form: true,
|
||||
},
|
||||
},
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
select: { name: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
_count: { select: { files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Find next unevaluated project for "Next Project" navigation
|
||||
const now = new Date()
|
||||
const nextAssignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
id: { not: assignment?.id ?? undefined },
|
||||
round: {
|
||||
status: 'ACTIVE',
|
||||
votingStartAt: { lte: now },
|
||||
votingEndAt: { gte: now },
|
||||
},
|
||||
OR: [
|
||||
{ evaluation: null },
|
||||
{ evaluation: { status: { in: ['NOT_STARTED', 'DRAFT'] } } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { round: { votingEndAt: 'asc' } },
|
||||
})
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium text-destructive">Access Denied</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You are not assigned to evaluate this project
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const evaluation = assignment.evaluation
|
||||
|
||||
if (!evaluation || evaluation.status === 'NOT_STARTED') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/jury/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Evaluation Found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You haven't submitted an evaluation for this project yet.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={`/jury/projects/${projectId}/evaluate`}>
|
||||
Start Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse criteria from the evaluation form
|
||||
const criteria: Criterion[] =
|
||||
(evaluation.form.criteriaJson as unknown as Criterion[]) || []
|
||||
const criterionScores =
|
||||
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
|
||||
|
||||
const round = assignment.round
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{round.program.name}</span>
|
||||
<span>/</span>
|
||||
<span>{round.name}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
My Evaluation: {project.title}
|
||||
</h1>
|
||||
{project.teamName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{project.teamName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="default"
|
||||
className="w-fit bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{evaluation.status === 'LOCKED' ? 'Locked' : 'Submitted'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{evaluation.submittedAt && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Submitted on {format(new Date(evaluation.submittedAt), 'PPP')} at{' '}
|
||||
{format(new Date(evaluation.submittedAt), 'p')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Project Documents */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={project.id}
|
||||
roundId={round.id}
|
||||
fileCount={project._count?.files ?? 0}
|
||||
/>
|
||||
|
||||
{/* Criteria scores */}
|
||||
{criteria.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Criteria Scores</CardTitle>
|
||||
<CardDescription>
|
||||
Your ratings for each evaluation criterion
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{criteria.map((criterion) => {
|
||||
const score = criterionScores[criterion.id]
|
||||
return (
|
||||
<div key={criterion.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{criterion.label}</p>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">{score}</span>
|
||||
<span className="text-muted-foreground">
|
||||
/ {criterion.scale}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Visual score bar */}
|
||||
<div className="flex gap-1">
|
||||
{Array.from(
|
||||
{ length: criterion.scale },
|
||||
(_, i) => i + 1
|
||||
).map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
num <= score
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Overall Score</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
|
||||
<span className="text-3xl font-bold text-primary">
|
||||
{evaluation.globalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium">out of 10</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{evaluation.globalScore && evaluation.globalScore >= 8
|
||||
? 'Excellent'
|
||||
: evaluation.globalScore && evaluation.globalScore >= 6
|
||||
? 'Good'
|
||||
: evaluation.globalScore && evaluation.globalScore >= 4
|
||||
? 'Average'
|
||||
: 'Below Average'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recommendation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recommendation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg p-4 ${
|
||||
evaluation.binaryDecision
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-500/10 text-red-700 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{evaluation.binaryDecision ? (
|
||||
<>
|
||||
<ThumbsUp className="h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-semibold">Recommended to Advance</p>
|
||||
<p className="text-sm opacity-80">
|
||||
You voted YES for this project to advance to the next round
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ThumbsDown className="h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-semibold">Not Recommended</p>
|
||||
<p className="text-sm opacity-80">
|
||||
You voted NO for this project to advance
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feedback */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Written Feedback</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/jury/projects/${projectId}`}>View Project Details</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/jury/assignments">All Assignments</Link>
|
||||
</Button>
|
||||
{nextAssignment && (
|
||||
<Button asChild>
|
||||
<Link href={`/jury/projects/${nextAssignment.project.id}/evaluate`}>
|
||||
Next Project
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EvaluationSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-96" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-32 rounded-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function EvaluationViewPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
|
||||
return (
|
||||
<Suspense fallback={<EvaluationSkeleton />}>
|
||||
<EvaluationContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { FileViewerSkeleton } from '@/components/shared/file-viewer'
|
||||
import { ProjectFilesSection } from '@/components/jury/project-files-section'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Edit3,
|
||||
Tag,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { formatDistanceToNow, format, isPast, isFuture } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
async function ProjectContent({ projectId }: { projectId: string }) {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check if user is assigned to this project
|
||||
const assignment = await prisma.assignment.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
evaluation: true,
|
||||
round: {
|
||||
include: {
|
||||
program: {
|
||||
select: { name: true, year: true },
|
||||
},
|
||||
evaluationForms: {
|
||||
where: { isActive: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get project details
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
// User is not assigned to this project
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium text-destructive">Access Denied</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You are not assigned to evaluate this project
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const evaluation = assignment.evaluation
|
||||
const round = assignment.round
|
||||
const now = new Date()
|
||||
|
||||
// Check voting window
|
||||
const isVotingOpen =
|
||||
round.status === 'ACTIVE' &&
|
||||
round.votingStartAt &&
|
||||
round.votingEndAt &&
|
||||
new Date(round.votingStartAt) <= now &&
|
||||
new Date(round.votingEndAt) >= now
|
||||
|
||||
const isVotingUpcoming =
|
||||
round.votingStartAt && isFuture(new Date(round.votingStartAt))
|
||||
|
||||
const isVotingClosed =
|
||||
round.votingEndAt && isPast(new Date(round.votingEndAt))
|
||||
|
||||
// Determine evaluation status
|
||||
const getEvaluationStatus = () => {
|
||||
if (!evaluation)
|
||||
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
|
||||
switch (evaluation.status) {
|
||||
case 'DRAFT':
|
||||
return { label: 'In Progress', variant: 'secondary' as const, icon: Edit3 }
|
||||
case 'SUBMITTED':
|
||||
return { label: 'Submitted', variant: 'default' as const, icon: CheckCircle2 }
|
||||
case 'LOCKED':
|
||||
return { label: 'Locked', variant: 'default' as const, icon: CheckCircle2 }
|
||||
default:
|
||||
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
|
||||
}
|
||||
}
|
||||
|
||||
const status = getEvaluationStatus()
|
||||
const StatusIcon = status.icon
|
||||
|
||||
const canEvaluate =
|
||||
isVotingOpen &&
|
||||
evaluation?.status !== 'SUBMITTED' &&
|
||||
evaluation?.status !== 'LOCKED'
|
||||
|
||||
const canViewEvaluation =
|
||||
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Project Header */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{round.program.year} Edition</span>
|
||||
<span>/</span>
|
||||
<span>{round.name}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{project.title}
|
||||
</h1>
|
||||
{project.teamName && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{project.teamName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:items-end">
|
||||
<Badge variant={status.variant} className="w-fit">
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
{round.votingEndAt && (
|
||||
<DeadlineDisplay
|
||||
votingStartAt={round.votingStartAt}
|
||||
votingEndAt={round.votingEndAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
<Tag className="mr-1 h-3 w-3" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{canEvaluate && (
|
||||
<Button asChild>
|
||||
<Link href={`/jury/projects/${project.id}/evaluate`}>
|
||||
{evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canViewEvaluation && (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href={`/jury/projects/${project.id}/evaluation`}>
|
||||
View My Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isVotingOpen && !canViewEvaluation && (
|
||||
<Button disabled>
|
||||
{isVotingUpcoming
|
||||
? 'Voting Not Yet Open'
|
||||
: isVotingClosed
|
||||
? 'Voting Closed'
|
||||
: 'Evaluation Unavailable'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Main content grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Description - takes 2 columns on large screens */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Project Description
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.description ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<p className="whitespace-pre-wrap">{project.description}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Files */}
|
||||
<Suspense fallback={<FileViewerSkeleton />}>
|
||||
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Round Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<Calendar className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Round Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Round</span>
|
||||
<span className="text-sm font-medium">{round.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Program</span>
|
||||
<span className="text-sm font-medium">{round.program.name}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{round.votingStartAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Voting Opens</span>
|
||||
<span className="text-sm">
|
||||
{format(new Date(round.votingStartAt), 'PPp')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{round.votingEndAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Voting Closes</span>
|
||||
<span className="text-sm">
|
||||
{format(new Date(round.votingEndAt), 'PPp')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<RoundStatusBadge
|
||||
status={round.status}
|
||||
votingStartAt={round.votingStartAt}
|
||||
votingEndAt={round.votingEndAt}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Evaluation Progress */}
|
||||
{evaluation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Your Evaluation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<Badge variant={status.variant}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{evaluation.status === 'DRAFT' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last saved{' '}
|
||||
{formatDistanceToNow(new Date(evaluation.updatedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{evaluation.status === 'SUBMITTED' && evaluation.submittedAt && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submitted{' '}
|
||||
{formatDistanceToNow(new Date(evaluation.submittedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeadlineDisplay({
|
||||
votingStartAt,
|
||||
votingEndAt,
|
||||
}: {
|
||||
votingStartAt: Date | null
|
||||
votingEndAt: Date
|
||||
}) {
|
||||
const now = new Date()
|
||||
const endDate = new Date(votingEndAt)
|
||||
const startDate = votingStartAt ? new Date(votingStartAt) : null
|
||||
|
||||
if (startDate && isFuture(startDate)) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Opens {format(startDate, 'PPp')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPast(endDate)) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Closed {formatDistanceToNow(endDate, { addSuffix: true })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const daysRemaining = Math.ceil(
|
||||
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
const isUrgent = daysRemaining <= 3
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 text-sm ${
|
||||
isUrgent ? 'text-amber-600 font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{daysRemaining <= 0
|
||||
? `Due ${formatDistanceToNow(endDate, { addSuffix: true })}`
|
||||
: `${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundStatusBadge({
|
||||
status,
|
||||
votingStartAt,
|
||||
votingEndAt,
|
||||
}: {
|
||||
status: string
|
||||
votingStartAt: Date | null
|
||||
votingEndAt: Date | null
|
||||
}) {
|
||||
const now = new Date()
|
||||
const isVotingOpen =
|
||||
status === 'ACTIVE' &&
|
||||
votingStartAt &&
|
||||
votingEndAt &&
|
||||
new Date(votingStartAt) <= now &&
|
||||
new Date(votingEndAt) >= now
|
||||
|
||||
if (isVotingOpen) {
|
||||
return <Badge variant="default">Voting Open</Badge>
|
||||
}
|
||||
|
||||
if (status === 'ACTIVE' && votingStartAt && isFuture(new Date(votingStartAt))) {
|
||||
return <Badge variant="secondary">Upcoming</Badge>
|
||||
}
|
||||
|
||||
if (status === 'ACTIVE' && votingEndAt && isPast(new Date(votingEndAt))) {
|
||||
return <Badge variant="outline">Voting Closed</Badge>
|
||||
}
|
||||
|
||||
return <Badge variant="secondary">{status}</Badge>
|
||||
}
|
||||
|
||||
function ProjectSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-96" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:items-end">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-40" />
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FileViewerSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ProjectSkeleton />}>
|
||||
<ProjectContent projectId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
368
src/app/(jury)/jury/stages/[stageId]/assignments/page.tsx
Normal file
368
src/app/(jury)/jury/stages/[stageId]/assignments/page.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
stage?: {
|
||||
id: string
|
||||
name: string
|
||||
track: {
|
||||
name: string
|
||||
pipeline: { id: string; name: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignmentStatus(assignment: {
|
||||
evaluation?: { status: string } | null
|
||||
conflictOfInterest?: { id: string } | null
|
||||
}) {
|
||||
if (assignment.conflictOfInterest) return 'COI'
|
||||
if (!assignment.evaluation) return 'NOT_STARTED'
|
||||
return assignment.evaluation.status
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
)
|
||||
case 'DRAFT':
|
||||
return (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
)
|
||||
case 'COI':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldAlert className="mr-1 h-3 w-3" />
|
||||
COI Declared
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Not Started
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function StageAssignmentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
|
||||
const { data: stageInfo, isLoading: stageLoading } =
|
||||
trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: windowStatus } =
|
||||
trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
const isLoading = stageLoading || assignmentsLoading
|
||||
|
||||
const totalAssignments = assignments?.length ?? 0
|
||||
const completedCount = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length ?? 0
|
||||
const coiCount = assignments?.filter((a) => a.conflictOfInterest).length ?? 0
|
||||
const pendingCount = totalAssignments - completedCount - coiCount
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/jury/stages" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{stageInfo?.name ?? 'Stage Assignments'}
|
||||
</h1>
|
||||
{stageInfo && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stageInfo.track.name} · {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>
|
||||
)
|
||||
}
|
||||
311
src/app/(jury)/jury/stages/[stageId]/compare/page.tsx
Normal file
311
src/app/(jury)/jury/stages/[stageId]/compare/page.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GitCompare,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Type for assignment with included relations from stageAssignment.myAssignments
|
||||
type AssignmentWithRelations = {
|
||||
id: string
|
||||
projectId: string
|
||||
stageId: string
|
||||
isCompleted: boolean
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
teamName: string | null
|
||||
country: string | null
|
||||
tags: string[]
|
||||
description: string | null
|
||||
}
|
||||
evaluation?: {
|
||||
id: string
|
||||
status: string
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
submittedAt: Date | null
|
||||
} | null
|
||||
conflictOfInterest?: {
|
||||
id: string
|
||||
hasConflict: boolean
|
||||
conflictType: string | null
|
||||
reviewAction: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function StageComparePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: rawAssignments, isLoading: assignmentsLoading } =
|
||||
trpc.stageAssignment.myAssignments.useQuery({ stageId })
|
||||
const assignments = rawAssignments as AssignmentWithRelations[] | undefined
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: evaluations } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const criteria = stageForm?.criteriaJson?.filter(
|
||||
(c: { type?: string }) => c.type !== 'section_header'
|
||||
) ?? []
|
||||
|
||||
// Map evaluations by project ID
|
||||
const evalByProject = useMemo(() => {
|
||||
const map = new Map<string, (typeof evaluations extends (infer T)[] | undefined ? T : never)>()
|
||||
evaluations?.forEach((e) => {
|
||||
if (e.assignment?.projectId) {
|
||||
map.set(e.assignment.projectId, e)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [evaluations])
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else if (next.size < 4) {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAssignments = assignments?.filter((a) =>
|
||||
selectedIds.has(a.project.id)
|
||||
) ?? []
|
||||
|
||||
if (assignmentsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submittedAssignments = assignments?.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<GitCompare className="h-6 w-6" />
|
||||
Compare Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Select 2-4 evaluated projects to compare side-by-side
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submittedAssignments.length < 2 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Not enough evaluations</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You need at least 2 submitted evaluations to compare projects.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Project selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Select Projects ({selectedIds.size}/4)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{submittedAssignments.map((assignment) => {
|
||||
const isSelected = selectedIds.has(assignment.project.id)
|
||||
const eval_ = evalByProject.get(assignment.project.id)
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50',
|
||||
selectedIds.size >= 4 && !isSelected && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => toggleProject(assignment.project.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={selectedIds.size >= 4 && !isSelected}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{assignment.project.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
</div>
|
||||
{eval_?.globalScore != null && (
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
<Star className="mr-1 h-3 w-3 text-amber-500" />
|
||||
{eval_.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison table */}
|
||||
{selectedAssignments.length >= 2 && (
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Criterion</TableHead>
|
||||
{selectedAssignments.map((a) => (
|
||||
<TableHead key={a.id} className="text-center min-w-[120px]">
|
||||
<div className="truncate max-w-[120px]" title={a.project.title}>
|
||||
{a.project.title}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* Criterion rows */}
|
||||
{criteria.map((criterion: { id: string; label: string; type?: string; scale?: string | number }) => (
|
||||
<TableRow key={criterion.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{criterion.label}
|
||||
</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
const scores = eval_?.criterionScoresJson as Record<string, number | string | boolean> | null
|
||||
const score = scores?.[criterion.id]
|
||||
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{criterion.type === 'boolean' ? (
|
||||
score ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
) : criterion.type === 'text' ? (
|
||||
<span className="text-xs truncate max-w-[100px] block">
|
||||
{String(score ?? '—')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tabular-nums font-semibold">
|
||||
{typeof score === 'number' ? score.toFixed(1) : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Global score row */}
|
||||
<TableRow className="bg-muted/50 font-semibold">
|
||||
<TableCell>Global Score</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
<span className="tabular-nums text-lg">
|
||||
{eval_?.globalScore?.toFixed(1) ?? '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
|
||||
{/* Decision row */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Decision</TableCell>
|
||||
{selectedAssignments.map((a) => {
|
||||
const eval_ = evalByProject.get(a.project.id)
|
||||
return (
|
||||
<TableCell key={a.id} className="text-center">
|
||||
{eval_?.binaryDecision != null ? (
|
||||
<Badge variant={eval_.binaryDecision ? 'success' : 'destructive'}>
|
||||
{eval_.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
269
src/app/(jury)/jury/stages/[stageId]/live/page.tsx
Normal file
269
src/app/(jury)/jury/stages/[stageId]/live/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Pause,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function StageJuryLivePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string }>
|
||||
}) {
|
||||
const { stageId } = use(params)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [hasVoted, setHasVoted] = useState(false)
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Get live cursor for this stage
|
||||
const { data: cursorData } = trpc.live.getCursor.useQuery(
|
||||
{ stageId },
|
||||
{ enabled: !!stageId }
|
||||
)
|
||||
|
||||
const sessionId = cursorData?.sessionId ?? null
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
activeProject,
|
||||
isPaused,
|
||||
error: sseError,
|
||||
reconnect,
|
||||
} = useStageliveSse(sessionId)
|
||||
|
||||
// Reset vote state when active project changes
|
||||
const activeProjectId = activeProject?.id
|
||||
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
|
||||
|
||||
if (activeProjectId && activeProjectId !== lastVotedProjectId && hasVoted) {
|
||||
setHasVoted(false)
|
||||
setSelectedScore(null)
|
||||
}
|
||||
|
||||
const castVoteMutation = trpc.live.castStageVote.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Vote submitted!')
|
||||
setHasVoted(true)
|
||||
setSelectedScore(null)
|
||||
setLastVotedProjectId(activeProjectId ?? null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVote = () => {
|
||||
if (!sessionId || !activeProject || selectedScore === null) return
|
||||
castVoteMutation.mutate({
|
||||
sessionId,
|
||||
projectId: activeProject.id,
|
||||
score: selectedScore,
|
||||
})
|
||||
}
|
||||
|
||||
if (!cursorData && !stageInfo) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No live session active</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A live presentation session has not been started for this stage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header with connection status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Live Voting</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Wifi className="mr-1 h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<WifiOff className="mr-1 h-3 w-3" />
|
||||
Disconnected
|
||||
</Badge>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Button variant="outline" size="sm" onClick={reconnect}>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sseError && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{sseError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Paused overlay */}
|
||||
{isPaused ? (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Pause className="h-12 w-12 text-amber-600 mb-3" />
|
||||
<p className="text-lg font-semibold">Session Paused</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator has paused voting. Please wait...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : activeProject ? (
|
||||
<>
|
||||
{/* Active project card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
|
||||
{activeProject.teamName && (
|
||||
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
{activeProject.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm">{activeProject.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Voting controls */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500" />
|
||||
Cast Your Vote
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasVoted ? (
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-emerald-600 mb-3" />
|
||||
<p className="font-semibold">Vote submitted!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Waiting for the next project...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-5 gap-2 sm:grid-cols-10">
|
||||
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-12 text-lg font-bold tabular-nums',
|
||||
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light'
|
||||
)}
|
||||
onClick={() => setSelectedScore(score)}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-brand-blue hover:bg-brand-blue-light"
|
||||
size="lg"
|
||||
disabled={selectedScore === null || castVoteMutation.isPending}
|
||||
onClick={handleVote}
|
||||
>
|
||||
{castVoteMutation.isPending ? 'Submitting...' : 'Submit Vote'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Star className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">Waiting for next project...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The session administrator will advance to the next project.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, AlertCircle } from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
||||
|
||||
export default function StageEvaluatePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
// Fetch assignment details
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
// Fetch stage info for breadcrumb
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
|
||||
// Fetch or create evaluation draft
|
||||
const startEval = trpc.evaluation.startStage.useMutation()
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
// State for the evaluation returned by the mutation
|
||||
const [evaluation, setEvaluation] = useState<{
|
||||
id: string
|
||||
status: string
|
||||
criterionScoresJson?: unknown
|
||||
globalScore?: number | null
|
||||
binaryDecision?: boolean | null
|
||||
feedbackText?: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Start evaluation on first load if we have the assignment
|
||||
useEffect(() => {
|
||||
if (assignment && !evaluation && !startEval.isPending && (windowStatus?.isOpen ?? false)) {
|
||||
startEval.mutate(
|
||||
{ assignmentId: assignment.id, stageId },
|
||||
{ onSuccess: (data) => setEvaluation(data) }
|
||||
)
|
||||
}
|
||||
}, [assignment?.id, windowStatus?.isOpen])
|
||||
|
||||
const isLoading = assignmentLoading || startEval.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You don'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
|
||||
export default function ViewStageEvaluationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: evaluations, isLoading } =
|
||||
trpc.evaluation.listStageEvaluations.useQuery({ stageId, projectId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: stageForm } = trpc.evaluation.getStageForm.useQuery({ stageId })
|
||||
|
||||
const evaluation = evaluations?.[0] // Most recent evaluation
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!evaluation) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No evaluation found</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You haven'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { Route } from 'next'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileEdit,
|
||||
Eye,
|
||||
Users,
|
||||
MapPin,
|
||||
Tag,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { StageBreadcrumb } from '@/components/shared/stage-breadcrumb'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
|
||||
function EvalStatusCard({
|
||||
status,
|
||||
stageId,
|
||||
projectId,
|
||||
isWindowOpen,
|
||||
}: {
|
||||
status: string
|
||||
stageId: string
|
||||
projectId: string
|
||||
isWindowOpen: boolean
|
||||
}) {
|
||||
const isSubmitted = status === 'SUBMITTED'
|
||||
const isDraft = status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
isSubmitted ? 'success' : isDraft ? 'warning' : 'secondary'
|
||||
}
|
||||
>
|
||||
{isSubmitted ? 'Submitted' : isDraft ? 'In Progress' : 'Not Started'}
|
||||
</Badge>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{isSubmitted ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluation` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View Evaluation
|
||||
</Link>
|
||||
</Button>
|
||||
) : isWindowOpen ? (
|
||||
<Button size="sm" asChild className="bg-brand-blue hover:bg-brand-blue-light">
|
||||
<Link href={`/jury/stages/${stageId}/projects/${projectId}/evaluate` as Route}>
|
||||
<FileEdit className="mr-1 h-3 w-3" />
|
||||
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Window Closed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StageProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ stageId: string; projectId: string }>
|
||||
}) {
|
||||
const { stageId, projectId } = use(params)
|
||||
|
||||
const { data: assignment, isLoading: assignmentLoading } =
|
||||
trpc.stageAssignment.getMyAssignment.useQuery({ projectId, stageId })
|
||||
|
||||
const { data: stageInfo } = trpc.stage.getForJury.useQuery({ id: stageId })
|
||||
const { data: windowStatus } = trpc.evaluation.checkStageWindow.useQuery({ stageId })
|
||||
|
||||
const isWindowOpen = windowStatus?.isOpen ?? false
|
||||
|
||||
if (assignmentLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50 mb-3" />
|
||||
<p className="font-medium text-destructive">Assignment not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const project = assignment.project
|
||||
const evalStatus = assignment.evaluation?.status ?? 'NOT_STARTED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back + Breadcrumb */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={`/jury/stages/${stageId}/assignments` as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{stageInfo && (
|
||||
<StageBreadcrumb
|
||||
pipelineName={stageInfo.track.pipeline.name}
|
||||
trackName={stageInfo.track.name}
|
||||
stageName={stageInfo.name}
|
||||
stageId={stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{project.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
|
||||
{project.teamName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{project.teamName}
|
||||
</span>
|
||||
)}
|
||||
{project.country && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{project.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stageInfo?.windowOpenAt}
|
||||
windowCloseAt={stageInfo?.windowCloseAt}
|
||||
status={stageInfo?.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project description */}
|
||||
{project.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
{project.tags.map((tag: string) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evaluation status */}
|
||||
<EvalStatusCard
|
||||
status={evalStatus}
|
||||
stageId={stageId}
|
||||
projectId={projectId}
|
||||
isWindowOpen={isWindowOpen}
|
||||
/>
|
||||
|
||||
{/* Project files */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={projectId}
|
||||
fileCount={project.files?.length ?? 0}
|
||||
stageId={stageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
247
src/app/(jury)/jury/stages/page.tsx
Normal file
247
src/app/(jury)/jury/stages/page.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Target,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { StageWindowBadge } from '@/components/shared/stage-window-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function JuryStagesDashboard() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const { data: stages, isLoading: stagesLoading } =
|
||||
trpc.stageAssignment.myStages.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const totalAssignments = stages?.reduce((sum, s) => sum + s.stats.total, 0) ?? 0
|
||||
const totalCompleted = stages?.reduce((sum, s) => sum + s.stats.completed, 0) ?? 0
|
||||
const totalInProgress = stages?.reduce((sum, s) => sum + s.stats.inProgress, 0) ?? 0
|
||||
const totalPending = totalAssignments - totalCompleted - totalInProgress
|
||||
const completionRate = totalAssignments > 0
|
||||
? Math.round((totalCompleted / totalAssignments) * 100)
|
||||
: 0
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Assignments',
|
||||
value: totalAssignments,
|
||||
icon: ClipboardList,
|
||||
accentColor: 'border-l-blue-500',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: totalCompleted,
|
||||
icon: CheckCircle2,
|
||||
accentColor: 'border-l-emerald-500',
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'In Progress',
|
||||
value: totalInProgress,
|
||||
icon: Clock,
|
||||
accentColor: 'border-l-amber-500',
|
||||
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: totalPending,
|
||||
icon: Target,
|
||||
accentColor: 'border-l-slate-400',
|
||||
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
]
|
||||
|
||||
if (stagesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Card key={i} className="border-l-4 border-l-muted">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<Skeleton className="h-11 w-11 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="py-5">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stages || stages.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
||||
<Layers className="h-8 w-8 text-brand-teal/60" />
|
||||
</div>
|
||||
<p className="text-lg font-semibold">No stage assignments yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
Your stage-based assignments will appear here once an administrator assigns projects to you.
|
||||
</p>
|
||||
<Button variant="outline" asChild className="mt-4">
|
||||
<Link href="/jury">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Stage Evaluations</h1>
|
||||
<p className="text-muted-foreground mt-0.5">Your stage-based evaluation assignments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<Card
|
||||
key={stat.label}
|
||||
className={cn('border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md', stat.accentColor)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className={cn('rounded-xl p-3', stat.iconBg)}>
|
||||
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card className="border-l-4 border-l-brand-teal">
|
||||
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||
{completionRate}%
|
||||
</p>
|
||||
<Progress value={completionRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage cards */}
|
||||
<div className="space-y-3">
|
||||
{stages.map((stage) => {
|
||||
const stageProgress = stage.stats.total > 0
|
||||
? Math.round((stage.stats.completed / stage.stats.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Card key={stage.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="py-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{stage.name}</h3>
|
||||
<StageWindowBadge
|
||||
windowOpenAt={stage.windowOpenAt}
|
||||
windowCloseAt={stage.windowCloseAt}
|
||||
status={stage.status}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stage.track.name} · {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user