MOPC-App/src/app/(jury)/jury/projects/[id]/page.tsx

490 lines
15 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,
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, year: 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.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="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>
)
}