Initial commit: MOPC platform with Docker deployment setup
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:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { SubmissionDetailClient } from './submission-detail-client'
export const dynamic = 'force-dynamic'
export default function SubmissionDetailPage() {
return <SubmissionDetailClient />
}

View File

@@ -0,0 +1,335 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { useSession } from 'next-auth/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 { StatusTracker } from '@/components/shared/status-tracker'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
Download,
Video,
File,
Users,
Crown,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText,
BUSINESS_PLAN: FileText,
PRESENTATION: FileText,
VIDEO_PITCH: Video,
VIDEO: Video,
OTHER: File,
SUPPORTING_DOC: File,
}
const fileTypeLabels: Record<string, string> = {
EXEC_SUMMARY: 'Executive Summary',
BUSINESS_PLAN: 'Business Plan',
PRESENTATION: 'Presentation',
VIDEO_PITCH: 'Video Pitch',
VIDEO: 'Video',
OTHER: 'Other Document',
SUPPORTING_DOC: 'Supporting Document',
}
export function SubmissionDetailClient() {
const params = useParams()
const { data: session } = useSession()
const projectId = params.id as string
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ enabled: !!session?.user }
)
if (isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
)
}
if (error || !statusData) {
return (
<div className="max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error?.message || 'Submission not found'}
</AlertDescription>
</Alert>
<Button asChild className="mt-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
)
}
const { project, timeline, currentStatus } = statusData
const isDraft = !project.submittedAt
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
</div>
<p className="text-muted-foreground">
{project.round.program.name} {project.round.program.year} - {project.round.name}
</p>
</div>
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Files */}
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
<CardDescription>
Documents submitted with your application
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">{file.fileName}</p>
<p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
</div>
</div>
<Button variant="ghost" size="sm" disabled>
<Download className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,426 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Users,
UserPlus,
Crown,
Mail,
Trash2,
ArrowLeft,
Loader2,
AlertCircle,
CheckCircle,
Clock,
LogIn,
} from 'lucide-react'
import Link from 'next/link'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
type InviteFormData = z.infer<typeof inviteSchema>
const roleLabels: Record<string, string> = {
LEAD: 'Team Lead',
MEMBER: 'Team Member',
ADVISOR: 'Advisor',
}
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
ACTIVE: { label: 'Active', icon: CheckCircle },
INVITED: { label: 'Pending', icon: Clock },
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function TeamManagementPage() {
const params = useParams()
const router = useRouter()
const projectId = params.id as string
const { data: session, status: sessionStatus } = useSession()
const [isInviteOpen, setIsInviteOpen] = useState(false)
const { data: teamData, isLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId },
{ enabled: sessionStatus === 'authenticated' && session?.user?.role === 'APPLICANT' }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member invited!')
setIsInviteOpen(false)
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
name: '',
email: '',
role: 'MEMBER',
title: '',
},
})
const onInvite = async (data: InviteFormData) => {
await inviteMutation.mutateAsync({
projectId,
...data,
})
form.reset()
}
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to manage your team.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
// Check if user is team lead
const currentUserMember = teamData?.teamMembers.find(
(tm) => tm.userId === session?.user?.id
)
const isTeamLead =
currentUserMember?.role === 'LEAD' ||
teamData?.submittedBy?.id === session?.user?.id
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/my-submission/${projectId}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
</h1>
<p className="text-muted-foreground">
Manage your project team
</p>
</div>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
return (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name}</span>
<Badge variant="outline" className="text-xs">
{roleLabels[member.role] || member.role}
</Badge>
{member.title && (
<span className="text-xs text-muted-foreground">
({member.title})
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{member.user.email}
<StatusIcon className="h-3 w-3 ml-2" />
<span className="text-xs">
{statusLabels[member.user.status]?.label || member.user.status}
</span>
</div>
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {member.user.name} from the team?
They will no longer have access to this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No team members yet.</p>
{isTeamLead && (
<Button
variant="outline"
className="mt-4"
onClick={() => setIsInviteOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import Link from 'next/link'
import { useSession } from 'next-auth/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 { StatusTracker } from '@/components/shared/status-tracker'
import {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
LogIn,
Eye,
Users,
Crown,
UserPlus,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
export function MySubmissionClient() {
const { data: session, status: sessionStatus } = useSession()
const { data: submissions, isLoading } = trpc.applicant.listMySubmissions.useQuery(
undefined,
{ enabled: session?.user?.role === 'APPLICANT' }
)
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to view your submissions.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading session
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-8 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
// Not an applicant
if (session?.user?.role !== 'APPLICANT') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-warning mb-4" />
<h2 className="text-xl font-semibold mb-2">Access Restricted</h2>
<p className="text-muted-foreground text-center">
This page is only available to applicants.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Submissions</h1>
<p className="text-muted-foreground">
Track the status of your project submissions
</p>
</div>
{/* Submissions list */}
{!submissions || submissions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Submissions Yet</h2>
<p className="text-muted-foreground text-center">
You haven&apos;t submitted any projects yet.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{submissions.map((project) => (
<Card key={project.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{project.round.program.name} {project.round.program.year} - {project.round.name}
</CardDescription>
</div>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Meta info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft - Not submitted
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{project.files.length} file(s) uploaded
</div>
{'teamMembers' in project && project.teamMembers && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
{project.teamMembers.length} team member(s)
</div>
)}
{'isTeamLead' in project && project.isTeamLead && (
<div className="flex items-center gap-1">
<Crown className="h-4 w-4 text-yellow-500" />
Team Lead
</div>
)}
</div>
{/* Status timeline */}
{project.submittedAt && (
<div className="pt-2">
<StatusTracker
timeline={[
{
status: 'SUBMITTED',
label: 'Submitted',
date: project.submittedAt,
completed: true,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null,
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(project.status),
},
]}
currentStatus={project.status}
className="mt-4"
/>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/my-submission/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { MySubmissionClient } from './my-submission-client'
export const dynamic = 'force-dynamic'
export default function MySubmissionPage() {
return <MySubmissionClient />
}