319 lines
9.3 KiB
TypeScript
319 lines
9.3 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|