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

View File

@@ -0,0 +1,177 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Plus, UserMinus } from 'lucide-react'
import { toast } from 'sonner'
export default function ProjectAssignmentsPage() {
const params = useParams()
const id = params.id as string
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id })
const { data: assignments = [], isLoading: assignmentsLoading } = trpc.assignment.listByProject.useQuery({ projectId: id })
const utils = trpc.useUtils()
const removeAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
toast.success('Assignment removed')
utils.assignment.listByProject.invalidate({ projectId: id })
},
onError: (error) => {
toast.error(error.message || 'Failed to remove assignment')
},
})
// Remove handled via AlertDialog in JSX
const isLoading = projectLoading || assignmentsLoading
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/projects/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Jury Assignments</h1>
<p className="text-muted-foreground">
{project?.title}
</p>
</div>
</div>
<Button asChild>
<Link href={`/admin/rounds/${project?.roundId}/assignments`}>
<Plus className="mr-2 h-4 w-4" />
Manage in Round
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Assigned Jury Members</CardTitle>
<CardDescription>
{assignments.length} jury member{assignments.length !== 1 ? 's' : ''} assigned to evaluate this project
</CardDescription>
</CardHeader>
<CardContent>
{assignments.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No jury members assigned yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Jury Member</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<div>
<p className="font-medium">{assignment.user.name}</p>
<p className="text-sm text-muted-foreground">{assignment.user.email}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={assignment.evaluation?.status === 'SUBMITTED' ? 'success' : 'secondary'}>
{assignment.evaluation?.status || 'Pending'}
</Badge>
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={removeAssignment.isPending}
>
<UserMinus className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Assignment</AlertDialogTitle>
<AlertDialogDescription>
Remove this jury member from the project? Their evaluation data will also be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAssignment.mutate({ id: assignment.id })}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,669 @@
'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogo } from '@/components/shared/project-logo'
import { LogoUpload } from '@/components/shared/logo-upload'
import {
ArrowLeft,
Loader2,
AlertCircle,
Trash2,
X,
Plus,
FileText,
Film,
Presentation,
FileIcon,
} from 'lucide-react'
import { formatFileSize } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
const updateProjectSchema = z.object({
title: z.string().min(1, 'Title is required').max(500),
teamName: z.string().optional(),
description: z.string().optional(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
tags: z.array(z.string()),
})
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
// File type icons
const fileTypeIcons: Record<string, React.ReactNode> = {
EXEC_SUMMARY: <FileText className="h-4 w-4" />,
PRESENTATION: <Presentation className="h-4 w-4" />,
VIDEO: <Film className="h-4 w-4" />,
OTHER: <FileIcon className="h-4 w-4" />,
}
function EditProjectContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [tagInput, setTagInput] = useState('')
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch files
const { data: files, refetch: refetchFiles } = trpc.file.listByProject.useQuery({
projectId,
})
// Fetch logo URL
const { data: logoUrl, refetch: refetchLogo } = trpc.logo.getUrl.useQuery({
projectId,
})
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundId,
})
// Mutations
const updateProject = trpc.project.update.useMutation({
onSuccess: () => {
router.push(`/admin/projects/${projectId}`)
},
})
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
router.push('/admin/projects')
},
})
const deleteFile = trpc.file.delete.useMutation({
onSuccess: () => {
refetchFiles()
},
})
// Initialize form
const form = useForm<UpdateProjectForm>({
resolver: zodResolver(updateProjectSchema),
defaultValues: {
title: '',
teamName: '',
description: '',
status: 'SUBMITTED',
tags: [],
},
})
// Update form when project loads
useEffect(() => {
if (project) {
form.reset({
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: project.status as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
}, [project, form])
const tags = form.watch('tags')
// Add tag
const addTag = useCallback(() => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
setTagInput('')
}
}, [tagInput, tags, form])
// Remove tag
const removeTag = useCallback(
(tag: string) => {
form.setValue(
'tags',
tags.filter((t) => t !== tag)
)
},
[tags, form]
)
const onSubmit = async (data: UpdateProjectForm) => {
await updateProject.mutateAsync({
id: projectId,
title: data.title,
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
tags: data.tags,
})
}
const handleDelete = async () => {
await deleteProject.mutateAsync({ id: projectId })
}
const handleDeleteFile = async (fileId: string) => {
await deleteFile.mutateAsync({ id: fileId })
}
if (isLoading) {
return <EditProjectSkeleton />
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</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">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const isPending = updateProject.isPending || deleteProject.isPending
// Filter tag suggestions (exclude already selected)
const tagSuggestions =
existingTags?.filter((t) => !tags.includes(t)).slice(0, 5) || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Project</h1>
<p className="text-muted-foreground">
Update project information and manage files
</p>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Logo */}
<div className="flex items-start gap-4 pb-4 border-b">
<ProjectLogo
project={project}
logoUrl={logoUrl}
size="lg"
/>
<div className="flex-1 space-y-1">
<FormLabel>Project Logo</FormLabel>
<FormDescription>
Upload a logo for this project. It will be displayed in project lists and cards.
</FormDescription>
<div className="pt-2">
<LogoUpload
project={project}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</div>
</div>
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Project title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>Team Name</FormLabel>
<FormControl>
<Input
placeholder="Team or organization name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Project description..."
rows={4}
maxLength={2000}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
<SelectItem value="ASSIGNED">Assigned</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Tags */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tags</CardTitle>
<CardDescription>
Add tags to categorize this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Add a tag..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
<Plus className="h-4 w-4" />
</Button>
</div>
{tagSuggestions.length > 0 && tagInput && (
<div className="flex flex-wrap gap-2">
<span className="text-xs text-muted-foreground">
Suggestions:
</span>
{tagSuggestions
.filter((t) =>
t.toLowerCase().includes(tagInput.toLowerCase())
)
.map((tag) => (
<Badge
key={tag}
variant="outline"
className="cursor-pointer hover:bg-muted"
onClick={() => {
if (!tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
}
setTagInput('')
}}
>
{tag}
</Badge>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Files */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardDescription>
Manage project documents and materials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Type</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2">
{fileTypeIcons[file.fileType] || (
<FileIcon className="h-4 w-4" />
)}
<span className="text-sm truncate max-w-[200px]">
{file.fileName}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{file.fileType.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete file?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{file.fileName}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteFile(file.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">
No files uploaded yet
</p>
)}
<div className="pt-4 border-t">
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
onUploadComplete={() => refetchFiles()}
/>
</div>
</CardContent>
</Card>
{/* Error Display */}
{updateProject.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{updateProject.error.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
{updateProject.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</div>
</form>
</Form>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently affect this project
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deleteProject.isPending}>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete Project
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{project.title}&quot; and all
associated files, assignments, and evaluations. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{deleteProject.error && (
<p className="mt-2 text-sm text-destructive">
{deleteProject.error.message}
</p>
)}
</CardContent>
</Card>
</div>
)
}
function EditProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-24 w-full" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function EditProjectPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<EditProjectSkeleton />}>
<EditProjectContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,393 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
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 { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Sparkles,
User,
Check,
Wand2,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
if (!project) {
return (
<Card>
<CardContent className="py-12 text-center">
<p>Project not found</p>
</CardContent>
</Card>
)
}
const hasMentor = !!project.mentorAssignment
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Wand2 className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>
)
}
function MentorAssignmentSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-48" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,653 @@
'use client'
import { Suspense, use } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
ArrowLeft,
Edit,
AlertCircle,
Users,
FileText,
Calendar,
CheckCircle2,
XCircle,
Clock,
BarChart3,
ThumbsUp,
ThumbsDown,
MapPin,
Waves,
GraduationCap,
Heart,
Crown,
UserPlus,
} from 'lucide-react'
import { formatDate, formatDateOnly, getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'default',
FINALIST: 'default',
REJECTED: 'destructive',
}
// Evaluation status colors
const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
NOT_STARTED: 'outline',
DRAFT: 'secondary',
SUBMITTED: 'default',
LOCKED: 'default',
}
function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch files
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
})
// Fetch evaluation stats
const { data: stats } = trpc.evaluation.getProjectStats.useQuery({
projectId,
})
const utils = trpc.useUtils()
if (isLoading) {
return <ProjectDetailSkeleton />
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</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">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<ProjectLogoWithUrl
project={project}
size="lg"
fallback="initials"
/>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
href={`/admin/rounds/${project.round.id}`}
className="hover:underline"
>
{project.round.name}
</Link>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</div>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>
)}
</div>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/projects/${projectId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
<Separator />
{/* Stats Grid */}
{stats && (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.averageGlobalScore?.toFixed(1) || '-'}
</div>
<p className="text-xs text-muted-foreground">
Range: {stats.minScore || '-'} - {stats.maxScore || '-'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Recommendations
</CardTitle>
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.yesPercentage?.toFixed(0) || 0}%
</div>
<p className="text-xs text-muted-foreground">
{stats.yesVotes} yes / {stats.noVotes} no
</p>
</CardContent>
</Card>
</div>
)}
{/* Project Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">
Description
</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location & Institution */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
</div>
{/* Submission URLs */}
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
<div className="flex flex-wrap gap-2">
{project.phase1SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 1 Submission
</a>
</Button>
)}
{project.phase2SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 2 Submission
</a>
</Button>
)}
</div>
</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="secondary">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Internal Info */}
{(project.internalComments || project.applicationStatus || project.referralSource) && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
<div className="grid gap-3 sm:grid-cols-2">
{project.applicationStatus && (
<div>
<p className="text-xs text-muted-foreground">Application Status</p>
<p className="text-sm">{project.applicationStatus}</p>
</div>
)}
{project.referralSource && (
<div>
<p className="text-xs text-muted-foreground">Referral Source</p>
<p className="text-sm">{project.referralSource}</p>
</div>
)}
</div>
{project.internalComments && (
<div className="mt-3">
<p className="text-xs text-muted-foreground">Comments</p>
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-6 text-sm pt-2">
<div>
<span className="text-muted-foreground">Created:</span>{' '}
{formatDateOnly(project.createdAt)}
</div>
<div>
<span className="text-muted-foreground">Updated:</span>{' '}
{formatDateOnly(project.updatedAt)}
</div>
</div>
</CardContent>
</Card>
{/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members ({project.teamMembers.length})
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string } }) => (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
<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">
{getInitials(member.user.name || member.user.email)}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Mentor Assignment Section */}
{project.wantsMentorship && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Heart className="h-5 w-5" />
Mentor Assignment
</CardTitle>
{!project.mentorAssignment && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects/${projectId}/mentor` as Route}>
<UserPlus className="mr-2 h-4 w-4" />
Assign Mentor
</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
{project.mentorAssignment ? (
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="text-sm">
{getInitials(project.mentorAssignment.mentor.name || project.mentorAssignment.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{project.mentorAssignment.mentor.name || 'Unnamed'}
</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.email}
</p>
</div>
</div>
<Badge variant="outline">
{project.mentorAssignment.method.replace('_', ' ')}
</Badge>
</div>
) : (
<p className="text-sm text-muted-foreground">
No mentor assigned yet. The applicant has requested mentorship support.
</p>
)}
</CardContent>
</Card>
)}
{/* Files Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardDescription>
Project documents and materials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<FileViewer
files={files.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType,
mimeType: f.mimeType,
size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
}))}
/>
) : (
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
)}
<Separator className="my-4" />
<div>
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
}}
/>
</div>
</CardContent>
</Card>
{/* Assignments Section */}
{assignments && assignments.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Jury Assignments</CardTitle>
<CardDescription>
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
.length}{' '}
of {assignments.length} evaluations completed
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(assignment.user.name || assignment.user.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-sm">
{assignment.user.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground">
{assignment.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{(assignment.user.expertiseTags?.length || 0) > 2 && (
<Badge variant="outline" className="text-xs">
+{(assignment.user.expertiseTags?.length || 0) - 2}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
evalStatusColors[
assignment.evaluation?.status || 'NOT_STARTED'
] || 'secondary'
}
>
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
'_',
' '
)}
</Badge>
</TableCell>
<TableCell>
{assignment.evaluation?.globalScore !== null &&
assignment.evaluation?.globalScore !== undefined ? (
<span className="font-medium">
{assignment.evaluation.globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.binaryDecision !== null &&
assignment.evaluation?.binaryDecision !== undefined ? (
assignment.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)
}
function ProjectDetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-10 w-24" />
</div>
<Skeleton className="h-px w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<ProjectDetailSkeleton />}>
<ProjectDetailContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { CSVImportForm } from '@/components/forms/csv-import-form'
import { NotionImportForm } from '@/components/forms/notion-import-form'
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
function ImportPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Fetch active programs with rounds
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Get all rounds from programs
const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({
...r,
programName: p.name,
}))
) || []
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
if (loadingPrograms) {
return <ImportPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
<p className="text-muted-foreground">
Import projects from a CSV file into a round
</p>
</div>
{/* Round selection */}
{!selectedRoundId && (
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>
Choose the round you want to import projects into
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a round first before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new">Create Round</Link>
</Button>
</div>
) : (
<>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
<div className="flex flex-col">
<span>{round.name}</span>
<span className="text-xs text-muted-foreground">
{round.programName}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
if (selectedRoundId) {
router.push(`/admin/projects/import?round=${selectedRoundId}`)
}
}}
disabled={!selectedRoundId}
>
Continue
</Button>
</>
)}
</CardContent>
</Card>
)}
{/* Import form */}
{selectedRoundId && selectedRound && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedRound.name}</p>
<p className="text-sm text-muted-foreground">
{selectedRound.programName}
</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => {
setSelectedRoundId('')
router.push('/admin/projects/import')
}}
>
Change Round
</Button>
</div>
<Tabs defaultValue="csv" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="csv" className="flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4" />
CSV
</TabsTrigger>
<TabsTrigger value="notion" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Notion
</TabsTrigger>
<TabsTrigger value="typeform" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Typeform
</TabsTrigger>
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</div>
)
}
function ImportPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-24" />
</CardContent>
</Card>
</div>
)
}
export default function ImportProjectsPage() {
return (
<Suspense fallback={<ImportPageSkeleton />}>
<ImportPageContent />
</Suspense>
)
}

View File

@@ -0,0 +1,418 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { TagInput } from '@/components/shared/tag-input'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
AlertCircle,
FolderPlus,
Plus,
X,
} from 'lucide-react'
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Form state
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState<string[]>([])
const [contactEmail, setContactEmail] = useState('')
const [contactName, setContactName] = useState('')
const [country, setCountry] = useState('')
const [customFields, setCustomFields] = useState<{ key: string; value: string }[]>([])
// Fetch active programs with rounds
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Create mutation
const createProject = trpc.project.create.useMutation({
onSuccess: () => {
toast.success('Project created successfully')
router.push(`/admin/projects?round=${selectedRoundId}`)
},
onError: (error) => {
toast.error(error.message)
},
})
// Get all rounds from programs
const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({
...r,
programName: p.name,
}))
) || []
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }])
}
const updateCustomField = (index: number, key: string, value: string) => {
const newFields = [...customFields]
newFields[index] = { key, value }
setCustomFields(newFields)
}
const removeCustomField = (index: number) => {
setCustomFields(customFields.filter((_, i) => i !== index))
}
const handleSubmit = () => {
if (!title.trim()) {
toast.error('Please enter a project title')
return
}
if (!selectedRoundId) {
toast.error('Please select a round')
return
}
// Build metadata
const metadataJson: Record<string, unknown> = {}
if (contactEmail) metadataJson.contactEmail = contactEmail
if (contactName) metadataJson.contactName = contactName
if (country) metadataJson.country = country
// Add custom fields
customFields.forEach((field) => {
if (field.key.trim() && field.value.trim()) {
metadataJson[field.key.trim()] = field.value.trim()
}
})
createProject.mutate({
roundId: selectedRoundId,
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
metadataJson: Object.keys(metadataJson).length > 0 ? metadataJson : undefined,
})
}
if (loadingPrograms) {
return <NewProjectPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<FolderPlus className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Project</h1>
<p className="text-muted-foreground">
Manually create a new project submission
</p>
</div>
</div>
{/* Round selection */}
{!selectedRoundId ? (
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>
Choose the round for this project submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a round first before adding projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new">Create Round</Link>
</Button>
</div>
) : (
<>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</CardContent>
</Card>
) : (
<>
{/* Selected round info */}
<Card>
<CardContent className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{selectedRound?.programName}</p>
<p className="text-sm text-muted-foreground">{selectedRound?.name}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedRoundId('')}
>
Change Round
</Button>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardDescription>
Basic information about the project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Project Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Cleanup Initiative"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamName">Team/Organization Name</Label>
<Input
id="teamName"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="e.g., Blue Ocean Foundation"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the project..."
rows={4}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<TagInput
value={tags}
onChange={setTags}
placeholder="Select project tags..."
maxTags={10}
/>
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
<CardDescription>
Contact details for the project team
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contactName">Contact Name</Label>
<Input
id="contactName"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="e.g., John Smith"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="e.g., john@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="e.g., Monaco"
/>
</div>
</CardContent>
</Card>
</div>
{/* Custom Fields */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Additional Information</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCustomField}
>
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</CardTitle>
<CardDescription>
Add custom metadata fields for this project
</CardDescription>
</CardHeader>
<CardContent>
{customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No additional fields. Click &quot;Add Field&quot; to add custom information.
</p>
) : (
<div className="space-y-3">
{customFields.map((field, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Field name"
value={field.key}
onChange={(e) =>
updateCustomField(index, e.target.value, field.value)
}
className="flex-1"
/>
<Input
placeholder="Value"
value={field.value}
onChange={(e) =>
updateCustomField(index, field.key, e.target.value)
}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCustomField(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/admin/projects">Cancel</Link>
</Button>
<Button
onClick={handleSubmit}
disabled={createProject.isPending || !title.trim()}
>
{createProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Project
</Button>
</div>
</>
)}
</div>
)
}
function NewProjectPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-32" />
</div>
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function NewProjectPage() {
return (
<Suspense fallback={<NewProjectPageSkeleton />}>
<NewProjectPageContent />
</Suspense>
)
}

View File

@@ -0,0 +1,300 @@
import { Suspense } from 'react'
import Link from 'next/link'
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
ClipboardList,
Eye,
Pencil,
FileUp,
Users,
} from 'lucide-react'
import { formatDateOnly, truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
async function ProjectsContent() {
const projects = await prisma.project.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
select: {
id: true,
title: true,
teamName: true,
status: true,
logoKey: true,
createdAt: true,
round: {
select: {
id: true,
name: true,
status: true,
program: {
select: {
name: true,
},
},
},
},
_count: {
select: {
assignments: true,
files: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
if (projects.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects yet</p>
<p className="text-sm text-muted-foreground">
Import projects via CSV or create them manually
</p>
<div className="mt-4 flex gap-2">
<Button asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import CSV
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
SUBMITTED: 'secondary',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id} className="group relative cursor-pointer hover:bg-muted/50">
<TableCell>
<Link href={`/admin/projects/${project.id}`} className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className="font-medium hover:text-primary">
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<p>{project.round.name}</p>
<p className="text-sm text-muted-foreground">
{project.round.program.name}
</p>
</div>
</TableCell>
<TableCell>{project._count.files}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{projects.map((project) => (
<Link key={project.id} href={`/admin/projects/${project.id}`} className="block">
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">
{project.title}
</CardTitle>
<Badge variant={statusColors[project.status] || 'secondary'} className="shrink-0">
{project.status.replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round.name}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</>
)
}
function ProjectsSkeleton() {
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-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function ProjectsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage submitted projects across all rounds
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</div>
{/* Content */}
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsContent />
</Suspense>
</div>
)
}