MOPC-App/src/app/(jury)/jury/page.tsx

348 lines
11 KiB
TypeScript
Raw Normal View History

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'
import { CountdownTimer } from '@/components/shared/countdown-timer'
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,
},
},
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 grace periods for this user
const gracePeriods = await prisma.gracePeriod.findMany({
where: {
userId,
extendedUntil: { gte: new Date() },
},
select: {
roundId: true,
extendedUntil: true,
},
})
// Build a map of roundId -> latest extendedUntil
const graceByRound = new Map<string, Date>()
for (const gp of gracePeriods) {
const existing = graceByRound.get(gp.roundId)
if (!existing || gp.extendedUntil > existing) {
graceByRound.set(gp.roundId, gp.extendedUntil)
}
}
// 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 && (
<div className="flex items-center gap-2 flex-wrap">
<CountdownTimer
deadline={graceByRound.get(round.id) ?? new Date(round.votingEndAt)}
label="Deadline:"
/>
<span className="text-xs text-muted-foreground">
({formatDateOnly(round.votingEndAt)})
</span>
</div>
)}
<Button asChild size="sm" className="w-full sm:w-auto">
<Link href={`/jury/assignments?round=${round.id}`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* No active rounds message */}
{activeRounds.length === 0 && totalAssignments > 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Clock className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No active voting rounds</p>
<p className="text-sm text-muted-foreground">
Check back later when a voting window opens
</p>
</CardContent>
</Card>
)}
{/* No assignments message */}
{totalAssignments === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments yet</p>
<p className="text-sm text-muted-foreground">
You&apos;ll see your project assignments here once they&apos;re
assigned
</p>
</CardContent>
</Card>
)}
</>
)
}
function DashboardSkeleton() {
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
<Skeleton className="mt-2 h-4 w-48" />
</CardContent>
</Card>
</>
)
}
export default async function JuryDashboardPage() {
const session = await auth()
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {session?.user?.name || 'Juror'}
</p>
</div>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />
</Suspense>
</div>
)
}