Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,362 @@
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 { formatDate, truncate } from '@/lib/utils'
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,
status: true,
files: {
select: {
id: true,
fileType: true,
},
},
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
updatedAt: 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()
return (
<div className="space-y-6">
{/* 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}>
<TableCell>
<div>
<p className="font-medium">
{truncate(assignment.project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
</div>
</TableCell>
<TableCell>
<div>
<p>{assignment.round.name}</p>
<p className="text-sm text-muted-foreground">
{assignment.round.program.name}
</p>
</div>
</TableCell>
<TableCell>
{assignment.round.votingEndAt ? (
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
) : (
<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 ? (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
) : (
<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}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-base">
{assignment.project.title}
</CardTitle>
<CardDescription>
{assignment.project.teamName}
</CardDescription>
</div>
{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>
<span>{formatDate(assignment.round.votingEndAt)}</span>
</div>
)}
<div className="pt-2">
{isCompleted ? (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View Evaluation
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" className="w-full" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link href={`/jury/projects/${assignment.project.id}`}>
View Project
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function AssignmentsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default async function JuryAssignmentsPage({
searchParams,
}: {
searchParams: Promise<{ round?: string }>
}) {
const params = await searchParams
const roundId = params.round
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Assignments</h1>
<p className="text-muted-foreground">
Projects assigned to you for evaluation
</p>
</div>
{/* Content */}
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentsContent roundId={roundId} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function JuryLearningPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
const utils = trpc.useUtils()
const handleDownload = async (resourceId: string) => {
setDownloadingId(resourceId)
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloadingId(null)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Educational resources for jury members
</p>
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const resources = data?.resources || []
const userCohortLevel = data?.userCohortLevel || 'ALL'
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Educational resources for jury members
</p>
{userCohortLevel !== 'ALL' && (
<Badge className={cohortColors[userCohortLevel]} variant="outline">
Your access level: {userCohortLevel}
</Badge>
)}
</div>
{resources.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources available</h3>
<p className="text-muted-foreground">
Check back later for learning materials
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
const isDownloading = downloadingId === resource.id
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
) : resource.objectKey ? (
<Button
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
) : null}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
'use client'
import { use, useState, useEffect } 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 { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap } from 'lucide-react'
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 [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data with polling
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
// 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)
} else {
setSelectedScore(null)
}
}, [data?.userVote, data?.currentProject?.id])
const handleVote = (score: number) => {
if (!data?.currentProject) return
setSelectedScore(score)
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
})
}
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>
{/* Score buttons */}
<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={() => handleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</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 */}
<p className="text-white/60 text-sm mt-4">
MOPC Live Voting
</p>
</div>
)
}
function JuryVotingSkeleton() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Skeleton className="h-6 w-32 mx-auto" />
<Skeleton className="h-4 w-48 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function JuryLiveVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <JuryVotingContent sessionId={sessionId} />
}

View File

@@ -0,0 +1,320 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const metadata: Metadata = { title: 'Jury Dashboard' }
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 { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
ClipboardList,
CheckCircle2,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
async function JuryDashboardContent() {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
// Get all assignments for this jury member
const assignments = await prisma.assignment.findMany({
where: {
userId,
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
status: true,
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
},
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' },
],
})
// Calculate stats
const totalAssignments = assignments.length
const completedAssignments = assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const inProgressAssignments = assignments.filter(
(a) => a.evaluation?.status === 'DRAFT'
).length
const pendingAssignments =
totalAssignments - completedAssignments - inProgressAssignments
const completionRate =
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
// Group assignments by round
const assignmentsByRound = assignments.reduce(
(acc, assignment) => {
const roundId = assignment.round.id
if (!acc[roundId]) {
acc[roundId] = {
round: assignment.round,
assignments: [],
}
}
acc[roundId].assignments.push(assignment)
return acc
},
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
)
// Get active rounds (voting window is 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
)
return (
<>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Assignments
</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<Clock className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{inProgressAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingAssignments}</div>
</CardContent>
</Card>
</div>
{/* Progress */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Progress</CardTitle>
</CardHeader>
<CardContent>
<Progress value={completionRate} className="h-3" />
<p className="mt-2 text-sm text-muted-foreground">
{completedAssignments} of {totalAssignments} evaluations completed (
{completionRate.toFixed(0)}%)
</p>
</CardContent>
</Card>
{/* Active Rounds */}
{activeRounds.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
<CardDescription>
These rounds are currently open for evaluation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{activeRounds.map(({ round, assignments: roundAssignments }) => {
const roundCompleted = roundAssignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const roundTotal = roundAssignments.length
const roundProgress =
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
return (
<div
key={round.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{round.name}</h3>
<p className="text-sm text-muted-foreground">
{round.program.name}
</p>
</div>
<Badge variant="default">Active</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span>
{roundCompleted}/{roundTotal}
</span>
</div>
<Progress value={roundProgress} className="h-2" />
</div>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Deadline: {formatDateOnly(round.votingEndAt)}
</p>
)}
<Button asChild size="sm" className="w-full sm:w-auto">
<Link href={`/jury/assignments?round=${round.id}`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* No active rounds message */}
{activeRounds.length === 0 && totalAssignments > 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Clock className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No active voting rounds</p>
<p className="text-sm text-muted-foreground">
Check back later when a voting window opens
</p>
</CardContent>
</Card>
)}
{/* No assignments message */}
{totalAssignments === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments yet</p>
<p className="text-sm text-muted-foreground">
You&apos;ll see your project assignments here once they&apos;re
assigned
</p>
</CardContent>
</Card>
)}
</>
)
}
function DashboardSkeleton() {
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
<Skeleton className="mt-2 h-4 w-48" />
</CardContent>
</Card>
</>
)
}
export default async function JuryDashboardPage() {
const session = await auth()
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {session?.user?.name || 'Juror'}
</p>
</div>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,318 @@
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 { EvaluationForm } from '@/components/forms/evaluation-form'
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
scale: number
weight?: number
required?: boolean
}
async function EvaluateContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
},
})
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 = project.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`)
}
// 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>
)}
{/* Evaluation Form */}
<EvaluationForm
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
criteria={criteria}
initialData={
evaluation
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
feedbackText: evaluation.feedbackText,
status: evaluation.status,
}
: undefined
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
/>
</div>
)
}
function EvaluateSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-80" />
<Skeleton className="h-4 w-32" />
</div>
<Card>
<CardContent className="p-6 space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluatePage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluateSkeleton />}>
<EvaluateContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,394 @@
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,
CheckCircle2,
ThumbsUp,
ThumbsDown,
Calendar,
Users,
Star,
AlertCircle,
} from 'lucide-react'
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')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
},
})
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const evaluation = assignment.evaluation
if (!evaluation || evaluation.status === 'NOT_STARTED') {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Evaluation Found</p>
<p className="text-sm text-muted-foreground">
You haven&apos;t submitted an evaluation for this project yet.
</p>
<Button asChild className="mt-4">
<Link href={`/jury/projects/${projectId}/evaluate`}>
Start Evaluation
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from the evaluation form
const criteria: Criterion[] =
(evaluation.form.criteriaJson as unknown as Criterion[]) || []
const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = project.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 />
{/* 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 justify-between">
<Button variant="outline" asChild>
<Link href={`/jury/projects/${projectId}`}>View Project Details</Link>
</Button>
<Button asChild>
<Link href="/jury/assignments">Back to All Assignments</Link>
</Button>
</div>
</div>
)
}
function EvaluationSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-px w-full" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-16" />
</div>
<Skeleton className="h-2 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-32 rounded-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluationViewPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluationSkeleton />}>
<EvaluationContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,489 @@
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 { FileViewer, FileViewerSkeleton } from '@/components/shared/file-viewer'
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')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: true,
},
})
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 = project.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.name}</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="text-lg">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 */}
<FileViewer files={project.files} />
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Round Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">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="text-lg">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>
)
}