Initial commit: MOPC platform with Docker deployment setup
Some checks failed
Build and Push Docker Image / build (push) Failing after 10s
Some checks failed
Build and Push Docker Image / build (push) Failing after 10s
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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user