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:
55
src/app/(jury)/error.tsx
Normal file
55
src/app/(jury)/error.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
|
||||
|
||||
export default function JuryError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Jury section error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-4">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
An error occurred while loading this page. Please try again or
|
||||
return to your assignments.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button onClick={reset} variant="outline">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/jury">
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
My Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
362
src/app/(jury)/jury/assignments/page.tsx
Normal file
362
src/app/(jury)/jury/assignments/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
src/app/(jury)/jury/learning/page.tsx
Normal file
168
src/app/(jury)/jury/learning/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
src/app/(jury)/jury/live/[sessionId]/page.tsx
Normal file
243
src/app/(jury)/jury/live/[sessionId]/page.tsx
Normal 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} />
|
||||
}
|
||||
320
src/app/(jury)/jury/page.tsx
Normal file
320
src/app/(jury)/jury/page.tsx
Normal 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'll see your project assignments here once they'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>
|
||||
)
|
||||
}
|
||||
318
src/app/(jury)/jury/projects/[id]/evaluate/page.tsx
Normal file
318
src/app/(jury)/jury/projects/[id]/evaluate/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
394
src/app/(jury)/jury/projects/[id]/evaluation/page.tsx
Normal file
394
src/app/(jury)/jury/projects/[id]/evaluation/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
489
src/app/(jury)/jury/projects/[id]/page.tsx
Normal file
489
src/app/(jury)/jury/projects/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/app/(jury)/layout.tsx
Normal file
36
src/app/(jury)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireRole } from '@/lib/auth-redirect'
|
||||
import { JuryNav } from '@/components/layouts/jury-nav'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function JuryLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await requireRole('JURY_MEMBER')
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { onboardingCompletedAt: true },
|
||||
})
|
||||
|
||||
if (!user?.onboardingCompletedAt) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<JuryNav
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="container-app py-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user