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,624 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Download,
Filter,
ChevronDown,
ChevronUp,
Clock,
User,
Activity,
Database,
Globe,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
// Action type options
const ACTION_TYPES = [
'CREATE',
'UPDATE',
'DELETE',
'IMPORT',
'EXPORT',
'LOGIN',
'SUBMIT_EVALUATION',
'UPDATE_STATUS',
'UPLOAD_FILE',
'DELETE_FILE',
'BULK_CREATE',
'BULK_UPDATE_STATUS',
'UPDATE_EVALUATION_FORM',
]
// Entity type options
const ENTITY_TYPES = [
'User',
'Program',
'Round',
'Project',
'Assignment',
'Evaluation',
'EvaluationForm',
'ProjectFile',
'GracePeriod',
]
// Color map for action types
const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'outline'> = {
CREATE: 'default',
UPDATE: 'secondary',
DELETE: 'destructive',
IMPORT: 'default',
EXPORT: 'outline',
LOGIN: 'outline',
SUBMIT_EVALUATION: 'default',
}
export default function AuditLogPage() {
// Filter state
const [filters, setFilters] = useState({
userId: '',
action: '',
entityType: '',
startDate: '',
endDate: '',
})
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [showFilters, setShowFilters] = useState(true)
// Build query input
const queryInput = useMemo(
() => ({
userId: filters.userId || undefined,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate
? new Date(filters.endDate + 'T23:59:59')
: undefined,
page,
perPage: 50,
}),
[filters, page]
)
// Fetch audit logs
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
// Fetch users for filter dropdown
const { data: usersData } = trpc.user.list.useQuery({
page: 1,
perPage: 100,
})
// Export mutation
const exportLogs = trpc.export.auditLogs.useQuery(
{
userId: filters.userId || undefined,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate
? new Date(filters.endDate + 'T23:59:59')
: undefined,
},
{ enabled: false }
)
// Handle export
const handleExport = async () => {
const result = await exportLogs.refetch()
if (result.data) {
const { data: rows, columns } = result.data
// Build CSV
const csvContent = [
columns.join(','),
...rows.map((row) =>
columns
.map((col) => {
const value = row[col as keyof typeof row]
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value ?? ''
})
.join(',')
),
].join('\n')
// Download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
}
// Reset filters
const resetFilters = () => {
setFilters({
userId: '',
action: '',
entityType: '',
startDate: '',
endDate: '',
})
setPage(1)
}
// Toggle row expansion
const toggleRow = (id: string) => {
const newExpanded = new Set(expandedRows)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedRows(newExpanded)
}
const hasFilters = Object.values(filters).some((v) => v !== '')
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">
View system activity and user actions
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleExport}
disabled={exportLogs.isFetching}
>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
</div>
{/* Filters */}
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<CardTitle className="text-lg">Filters</CardTitle>
{hasFilters && (
<Badge variant="secondary" className="ml-2">
Active
</Badge>
)}
</div>
{showFilters ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* User Filter */}
<div className="space-y-2">
<Label>User</Label>
<Select
value={filters.userId}
onValueChange={(v) =>
setFilters({ ...filters, userId: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All users</SelectItem>
{usersData?.users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name || user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action Filter */}
<div className="space-y-2">
<Label>Action</Label>
<Select
value={filters.action}
onValueChange={(v) =>
setFilters({ ...filters, action: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All actions</SelectItem>
{ACTION_TYPES.map((action) => (
<SelectItem key={action} value={action}>
{action.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Entity Type Filter */}
<div className="space-y-2">
<Label>Entity Type</Label>
<Select
value={filters.entityType}
onValueChange={(v) =>
setFilters({ ...filters, entityType: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All types</SelectItem>
{ENTITY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Start Date */}
<div className="space-y-2">
<Label>From Date</Label>
<Input
type="date"
value={filters.startDate}
onChange={(e) =>
setFilters({ ...filters, startDate: e.target.value })
}
/>
</div>
{/* End Date */}
<div className="space-y-2">
<Label>To Date</Label>
<Input
type="date"
value={filters.endDate}
onChange={(e) =>
setFilters({ ...filters, endDate: e.target.value })
}
/>
</div>
</div>
{hasFilters && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Filters
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Results */}
{isLoading ? (
<AuditLogSkeleton />
) : data && data.logs.length > 0 ? (
<>
{/* Desktop Table View */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>IP Address</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.logs.map((log) => {
const isExpanded = expandedRows.has(log.id)
return (
<>
<TableRow
key={log.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleRow(log.id)}
>
<TableCell className="font-mono text-xs">
{formatDate(log.timestamp)}
</TableCell>
<TableCell>
<div>
<p className="font-medium text-sm">
{log.user?.name || 'System'}
</p>
<p className="text-xs text-muted-foreground">
{log.user?.email}
</p>
</div>
</TableCell>
<TableCell>
<Badge
variant={actionColors[log.action] || 'secondary'}
>
{log.action.replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell>
<div>
<p className="text-sm">{log.entityType}</p>
{log.entityId && (
<p className="text-xs text-muted-foreground font-mono">
{log.entityId.slice(0, 8)}...
</p>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs">
{log.ipAddress || '-'}
</TableCell>
<TableCell>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${log.id}-details`}>
<TableCell colSpan={6} className="bg-muted/30">
<div className="p-4 space-y-2">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-sm">
{log.entityId || 'N/A'}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
User Agent
</p>
<p className="text-sm truncate max-w-md">
{log.userAgent || 'N/A'}
</p>
</div>
</div>
{log.detailsJson && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</Card>
{/* Mobile Card View */}
<div className="space-y-4 md:hidden">
{data.logs.map((log) => {
const isExpanded = expandedRows.has(log.id)
return (
<Card
key={log.id}
className="cursor-pointer"
onClick={() => toggleRow(log.id)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge
variant={actionColors[log.action] || 'secondary'}
>
{log.action.replace(/_/g, ' ')}
</Badge>
<span className="text-sm text-muted-foreground">
{log.entityType}
</span>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-3 w-3" />
<span className="text-xs">
{formatDate(log.timestamp)}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
<span className="text-xs">
{log.user?.name || 'System'}
</span>
</div>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-xs">
{log.entityId || 'N/A'}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
IP Address
</p>
<p className="font-mono text-xs">
{log.ipAddress || 'N/A'}
</p>
</div>
{log.detailsJson && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(page - 1) * 50 + 1} to{' '}
{Math.min(page * 50, data.total)} of {data.total} results
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Activity className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No audit logs found</p>
<p className="text-sm text-muted-foreground">
{hasFilters
? 'Try adjusting your filters'
: 'Activity will appear here as users interact with the system'}
</p>
</CardContent>
</Card>
)}
</div>
)
}
function AuditLogSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24 ml-auto" />
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,241 @@
'use client'
import { useState } from 'react'
import { useRouter } 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 { Switch } from '@/components/ui/switch'
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 { Loader2, Plus, Trash2, GripVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
interface FormEditorProps {
form: {
id: string
name: string
description: string | null
status: string
isPublic: boolean
publicSlug: string | null
submissionLimit: number | null
opensAt: Date | null
closesAt: Date | null
confirmationMessage: string | null
fields: Array<{
id: string
fieldType: string
name: string
label: string
description: string | null
placeholder: string | null
required: boolean
sortOrder: number
}>
}
}
export function FormEditor({ form }: FormEditorProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: form.name,
description: form.description || '',
status: form.status,
isPublic: form.isPublic,
publicSlug: form.publicSlug || '',
confirmationMessage: form.confirmationMessage || '',
})
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
toast.success('Form updated successfully')
router.refresh()
setIsSubmitting(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to update form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateForm.mutate({
id: form.id,
name: formData.name,
status: formData.status as 'DRAFT' | 'PUBLISHED' | 'CLOSED',
isPublic: formData.isPublic,
description: formData.description || null,
publicSlug: formData.publicSlug || null,
confirmationMessage: formData.confirmationMessage || null,
})
}
return (
<Tabs defaultValue="settings" className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="fields">Fields ({form.fields.length})</TabsTrigger>
</TabsList>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>
Configure the basic settings for this form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Form Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
value={formData.publicSlug}
onChange={(e) => setFormData({ ...formData, publicSlug: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
<Label htmlFor="isPublic">Public form (accessible without login)</Label>
</div>
<div className="space-y-2">
<Label htmlFor="confirmationMessage">Confirmation Message</Label>
<Textarea
id="confirmationMessage"
value={formData.confirmationMessage}
onChange={(e) => setFormData({ ...formData, confirmationMessage: e.target.value })}
placeholder="Thank you for your submission..."
rows={3}
maxLength={1000}
/>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Settings
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="fields">
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
<CardDescription>
Add and arrange the fields for your application form
</CardDescription>
</CardHeader>
<CardContent>
{form.fields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No fields added yet.</p>
<p className="text-sm">Add fields to start building your form.</p>
</div>
) : (
<div className="space-y-2">
{form.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 border rounded-lg"
>
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<div className="flex-1">
<div className="font-medium">{field.label}</div>
<div className="text-sm text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</div>
</div>
<Button variant="ghost" size="icon" aria-label="Delete field">
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="mt-4">
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@@ -0,0 +1,83 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Settings, Eye, FileText, Plus } from 'lucide-react'
import { FormEditor } from './form-editor'
interface FormDetailPageProps {
params: Promise<{ id: string }>
}
export default async function FormDetailPage({ params }: FormDetailPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{form.name}</h1>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<p className="text-muted-foreground">
{form.fields.length} fields - {form._count.submissions} submissions
</p>
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
Submissions
</Button>
</Link>
</div>
</div>
<FormEditor form={form} />
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Download, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionDetailPageProps {
params: Promise<{ id: string; submissionId: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id, submissionId } = await params
const caller = await api()
let submission
try {
submission = await caller.applicationForm.getSubmission({ id: submissionId })
} catch {
notFound()
}
const data = submission.dataJson as Record<string, unknown>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{submission.name || submission.email || 'Anonymous Submission'}
</h1>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-muted-foreground">
Submitted {formatDate(submission.createdAt)}
</p>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Submission Data</CardTitle>
<CardDescription>
All fields submitted in this application
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{submission.form.fields.map((field) => {
const value = data[field.name]
return (
<div key={field.id} className="border-b pb-4 last:border-0">
<div className="font-medium text-sm text-muted-foreground">
{field.label}
</div>
<div className="mt-1">
{value !== undefined && value !== null && value !== '' ? (
typeof value === 'object' ? (
<pre className="text-sm bg-muted p-2 rounded">
{JSON.stringify(value, null, 2)}
</pre>
) : (
<span>{String(value)}</span>
)
) : (
<span className="text-muted-foreground italic">Not provided</span>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{submission.files.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Attached Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{submission.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<div className="font-medium">{file.fileName}</div>
<div className="text-sm text-muted-foreground">
{file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size'}
</div>
</div>
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,135 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Inbox, Eye, Download } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionsPageProps {
params: Promise<{ id: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
async function SubmissionsList({ formId }: { formId: string }) {
const caller = await api()
const { data: submissions } = await caller.applicationForm.listSubmissions({
formId,
perPage: 50,
})
if (submissions.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Inbox className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No submissions yet</h3>
<p className="text-muted-foreground">
Submissions will appear here once people start filling out the form
</p>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{submissions.map((submission) => (
<Card key={submission.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium">
{submission.name || submission.email || 'Anonymous'}
</h3>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{submission.email && <span>{submission.email} - </span>}
Submitted {formatDate(submission.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/forms/${formId}/submissions/${submission.id}`}>
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default async function SubmissionsPage({ params }: SubmissionsPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Submissions</h1>
<p className="text-muted-foreground">
{form.name} - {form._count.submissions} total submissions
</p>
</div>
</div>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<SubmissionsList formId={id} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
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 { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Form created successfully')
router.push(`/admin/forms/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Application Form</h1>
<p className="text-muted-foreground">
Set up a new application form
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Basic information about your application form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., 2024 Project Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., 2024-applications"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/forms">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Copy,
MoreHorizontal,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function FormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application form
</p>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/forms/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function FormsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Application Forms</h1>
<p className="text-muted-foreground">
Create and manage custom application forms
</p>
</div>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<FormsList />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,473 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
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 { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
FileText,
Video,
Link as LinkIcon,
File,
Trash2,
Eye,
AlertCircle,
} from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function EditLearningResourcePage() {
const params = useParams()
const router = useRouter()
const resourceId = params.id as string
// Fetch resource
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const updateResource = trpc.learningResource.update.useMutation()
const deleteResource = trpc.learningResource.delete.useMutation()
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Populate form when resource loads
useEffect(() => {
if (resource) {
setTitle(resource.title)
setDescription(resource.description || '')
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
setResourceType(resource.resourceType)
setCohortLevel(resource.cohortLevel)
setExternalUrl(resource.externalUrl || '')
setIsPublished(resource.isPublished)
setProgramId(resource.programId)
}
}, [resource])
// Handle file upload for BlockNote
const handleUploadFile = async (file: File): Promise<string> => {
try {
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
})
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
toast.error('Failed to upload file')
throw error
}
}
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await updateResource.mutateAsync({
id: resourceId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || null,
isPublished,
})
toast.success('Resource updated successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
}
}
const handleDelete = async () => {
try {
await deleteResource.mutateAsync({ id: resourceId })
toast.success('Resource deleted successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<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-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
The resource you&apos;re looking for does not exist.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</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/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
<p className="text-muted-foreground">
Update this learning resource
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Statistics */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-sm text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-sm text-muted-foreground">Unique users</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
className="w-full"
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,324 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
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 { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function NewLearningResourcePage() {
const router = useRouter()
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const createResource = trpc.learningResource.create.useMutation()
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Handle file upload for BlockNote
const handleUploadFile = async (file: File): Promise<string> => {
try {
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
})
// Upload to MinIO
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
// Return the MinIO URL
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
toast.error('Failed to upload file')
throw error
}
}
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || undefined,
isPublished,
})
toast.success('Resource created successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
<p className="text-muted-foreground">
Create a new learning resource for jury members
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
className="w-full"
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Resource
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
FileText,
Video,
Link as LinkIcon,
File,
Eye,
Pencil,
ExternalLink,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
async function LearningResourcesList() {
const caller = await api()
const { data: resources } = await caller.learningResource.list({
perPage: 50,
})
if (resources.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources yet</h3>
<p className="text-muted-foreground mb-4">
Start by adding your first learning resource
</p>
<Link href="/admin/learning/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={cohortColors[resource.cohortLevel]} variant="outline">
{resource.cohortLevel}
</Badge>
<span>{resource.resourceType}</span>
<span>-</span>
<span>{resource._count.accessLogs} views</span>
</div>
</div>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function LearningHubPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for jury members
</p>
</div>
<Link href="/admin/learning/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<LearningResourcesList />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,788 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
export const metadata: Metadata = { title: 'Admin Dashboard' }
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
CircleDot,
ClipboardList,
Users,
CheckCircle2,
Calendar,
TrendingUp,
ArrowRight,
Layers,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import {
formatDateOnly,
formatEnumLabel,
truncate,
daysUntil,
} from '@/lib/utils'
type DashboardStatsProps = {
editionId: string | null
sessionName: string
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
if (!editionId) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No edition selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view dashboard
</p>
</CardContent>
</Card>
)
}
const edition = await prisma.program.findUnique({
where: { id: editionId },
select: { name: true, year: true },
})
if (!edition) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Edition not found</p>
<p className="text-sm text-muted-foreground">
The selected edition could not be found
</p>
</CardContent>
</Card>
)
}
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
] = await Promise.all([
prisma.round.count({
where: { programId: editionId, status: 'ACTIVE' },
}),
prisma.round.count({
where: { programId: editionId },
}),
prisma.project.count({
where: { round: { programId: editionId } },
}),
prisma.project.count({
where: {
round: { programId: editionId },
createdAt: { gte: sevenDaysAgo },
},
}),
prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED'] },
assignments: { some: { round: { programId: editionId } } },
},
}),
prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { programId: editionId } } },
},
}),
prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { programId: editionId } } },
_count: true,
}),
prisma.assignment.count({
where: { round: { programId: editionId } },
}),
prisma.round.findMany({
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
evaluation: { select: { status: true } },
},
},
},
}),
prisma.project.findMany({
where: { round: { programId: editionId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
teamName: true,
status: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
round: { select: { name: true } },
},
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { round: { programId: editionId } },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { round: { programId: editionId } },
_count: true,
}),
])
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
const draftCount =
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
const totalEvaluations = submittedCount + draftCount
const completionRate =
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
const invitedJurors = totalJurors - activeJurors
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round) => {
const submitted = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const total = round._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from rounds
const now = new Date()
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
deadlines.push({
label: 'Voting closes',
roundName: round.name,
date: new Date(round.votingEndAt),
})
}
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
deadlines.push({
label: 'Submissions close',
roundName: round.name,
date: new Date(round.submissionEndDate),
})
}
}
deadlines.sort((a, b) => a.date.getTime() - b.date.getTime())
const upcomingDeadlines = deadlines.slice(0, 4)
// Category/issue bars
const categories = categoryBreakdown
.filter((c) => c.competitionCategory !== null)
.map((c) => ({
label: formatEnumLabel(c.competitionCategory!),
count: c._count,
}))
.sort((a, b) => b.count - a.count)
const issues = oceanIssueBreakdown
.filter((i) => i.oceanIssue !== null)
.map((i) => ({
label: formatEnumLabel(i.oceanIssue!),
count: i._count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
return (
<>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {sessionName} &mdash; {edition.name} {edition.year}
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</div>
{/* Two-Column Content */}
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Voting rounds in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{roundsWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet
</p>
<Link
href="/admin/rounds/new"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Create your first round
</Link>
</div>
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}`}
className="block"
>
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'success'
: 'secondary'
}
>
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{round.votingStartAt && round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Voting: {formatDateOnly(round.votingStartAt)} &ndash; {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
)}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
{/* Latest Projects Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Latest Projects</CardTitle>
<CardDescription>Recently submitted projects</CardDescription>
</div>
<Link
href="/admin/projects"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{latestProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No projects submitted yet
</p>
</div>
) : (
<div className="space-y-1">
{latestProjects.map((project) => (
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-start gap-3 rounded-lg p-3 transition-colors hover:bg-muted/50">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight truncate">
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.status] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{project.status.replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
{(project.competitionCategory || project.oceanIssue) && (
<p className="text-xs text-muted-foreground/70 mt-0.5">
{[
project.competitionCategory
? formatEnumLabel(project.competitionCategory)
: null,
project.oceanIssue
? formatEnumLabel(project.oceanIssue)
: null,
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Evaluation Progress Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent>
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No evaluations in progress
</p>
</div>
) : (
<div className="space-y-5">
{roundsWithEvalStats
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
.map((round) => (
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{round.name}</p>
<span className="text-sm font-semibold tabular-nums">
{round.evalPercent}%
</span>
</div>
<Progress value={round.evalPercent} className="h-2" />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Category Breakdown Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-4 w-4" />
Project Categories
</CardTitle>
</CardHeader>
<CardContent>
{categories.length === 0 && issues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Layers className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No category data available
</p>
</div>
) : (
<div className="space-y-5">
{categories.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
By Type
</p>
{categories.map((cat) => (
<div key={cat.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{cat.label}</span>
<span className="font-medium tabular-nums">{cat.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
{issues.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Top Issues
</p>
{issues.map((issue) => (
<div key={issue.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{issue.label}</span>
<span className="font-medium tabular-nums">{issue.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Upcoming Deadlines Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Upcoming Deadlines
</CardTitle>
</CardHeader>
<CardContent>
{upcomingDeadlines.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Calendar className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No upcoming deadlines
</p>
</div>
) : (
<div className="space-y-4">
{upcomingDeadlines.map((deadline, i) => {
const days = daysUntil(deadline.date)
const isUrgent = days <= 7
return (
<div key={i} className="flex items-start gap-3">
<div className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${isUrgent ? 'bg-destructive/10' : 'bg-muted'}`}>
<Calendar className={`h-4 w-4 ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}
</p>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Geographic Distribution (full width, at the bottom) */}
<GeographicSummaryCard programId={editionId} />
</>
)
}
function DashboardSkeleton() {
return (
<>
{/* Header skeleton */}
<div>
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
{/* Stats grid skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
{/* Two-column content skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-7">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-52" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 lg:col-span-5">
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Map skeleton */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</>
)
}
type PageProps = {
searchParams: Promise<{ edition?: string }>
}
export default async function AdminDashboardPage({ searchParams }: PageProps) {
const [session, params] = await Promise.all([
auth(),
searchParams,
])
let editionId = params.edition || null
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = defaultEdition?.id || null
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = anyEdition?.id || null
}
}
const sessionName = session?.user?.name || 'Admin'
return (
<div className="space-y-6">
<Suspense fallback={<DashboardSkeleton />}>
<DashboardStats editionId={editionId} sessionName={sessionName} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
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 { Switch } from '@/components/ui/switch'
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
export default function EditPartnerPage() {
const router = useRouter()
const params = useParams()
const id = params.id as string
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
website: '',
partnerType: 'PARTNER',
visibility: 'ADMIN_ONLY',
isActive: true,
})
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
useEffect(() => {
if (partner) {
setFormData({
name: partner.name,
description: partner.description || '',
website: partner.website || '',
partnerType: partner.partnerType,
visibility: partner.visibility,
isActive: partner.isActive,
})
}
}, [partner])
const updatePartner = trpc.partner.update.useMutation({
onSuccess: () => {
toast.success('Partner updated successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to update partner')
setIsSubmitting(false)
},
})
const deletePartner = trpc.partner.delete.useMutation({
onSuccess: () => {
toast.success('Partner deleted successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to delete partner')
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updatePartner.mutate({
id,
name: formData.name,
description: formData.description || null,
website: formData.website || null,
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
isActive: formData.isActive,
})
}
// Delete handled via AlertDialog in JSX
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-48" />
<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-24 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/partners">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Edit Partner</h1>
<p className="text-muted-foreground">
Update partner information
</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this partner. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle>Partner Details</CardTitle>
<CardDescription>
Basic information about the partner organization
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Organization Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="partnerType">Partner Type</Label>
<Select
value={formData.partnerType}
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SPONSOR">Sponsor</SelectItem>
<SelectItem value="PARTNER">Partner</SelectItem>
<SelectItem value="SUPPORTER">Supporter</SelectItem>
<SelectItem value="MEDIA">Media</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
maxLength={500}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
type="url"
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="visibility">Visibility</Label>
<Select
value={formData.visibility}
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
<SelectItem value="PUBLIC">Public</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-8">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive">Active</Label>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/partners">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
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 { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewPartnerPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [partnerType, setPartnerType] = useState('PARTNER')
const [visibility, setVisibility] = useState('ADMIN_ONLY')
const createPartner = trpc.partner.create.useMutation({
onSuccess: () => {
toast.success('Partner created successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to create partner')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const website = formData.get('website') as string
createPartner.mutate({
name,
programId: null,
description: description || undefined,
website: website || undefined,
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/partners">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Add Partner</h1>
<p className="text-muted-foreground">
Add a new partner or sponsor organization
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Partner Details</CardTitle>
<CardDescription>
Basic information about the partner organization
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Organization Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., Ocean Conservation Foundation"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="partnerType">Partner Type</Label>
<Select value={partnerType} onValueChange={setPartnerType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SPONSOR">Sponsor</SelectItem>
<SelectItem value="PARTNER">Partner</SelectItem>
<SelectItem value="SUPPORTER">Supporter</SelectItem>
<SelectItem value="MEDIA">Media</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the organization and partnership..."
rows={3}
maxLength={500}
/>
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
name="website"
type="url"
placeholder="https://example.org"
/>
</div>
<div className="space-y-2">
<Label htmlFor="visibility">Visibility</Label>
<Select value={visibility} onValueChange={setVisibility}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
<SelectItem value="PUBLIC">Public</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/partners">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Partner
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
ExternalLink,
Building2,
Eye,
EyeOff,
Globe,
} from 'lucide-react'
const visibilityIcons = {
ADMIN_ONLY: EyeOff,
JURY_VISIBLE: Eye,
PUBLIC: Globe,
}
const partnerTypeColors = {
SPONSOR: 'bg-yellow-100 text-yellow-800',
PARTNER: 'bg-blue-100 text-blue-800',
SUPPORTER: 'bg-green-100 text-green-800',
MEDIA: 'bg-purple-100 text-purple-800',
OTHER: 'bg-gray-100 text-gray-800',
}
async function PartnersList() {
const caller = await api()
const { data: partners } = await caller.partner.list({
perPage: 50,
})
if (partners.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No partners yet</h3>
<p className="text-muted-foreground mb-4">
Start by adding your first partner organization
</p>
<Link href="/admin/partners/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Partner
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{partners.map((partner) => {
const VisibilityIcon = visibilityIcons[partner.visibility]
return (
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
<Building2 className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{partner.name}</h3>
{!partner.isActive && (
<Badge variant="secondary">Inactive</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge className={partnerTypeColors[partner.partnerType]} variant="outline">
{partner.partnerType}
</Badge>
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
</div>
{partner.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{partner.description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
{partner.website && (
<a
href={partner.website}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4 mr-1" />
Website
</Button>
</a>
)}
<Link href={`/admin/partners/${partner.id}`}>
<Button variant="ghost" size="sm">
<Pencil className="h-4 w-4 mr-1" />
Edit
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function PartnersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Partners</h1>
<p className="text-muted-foreground">
Manage partner and sponsor organizations
</p>
</div>
<Link href="/admin/partners/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Partner
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<PartnersList />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,226 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
export default function EditProgramPage() {
const router = useRouter()
const params = useParams()
const id = params.id as string
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
status: 'DRAFT',
})
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
useEffect(() => {
if (program) {
setFormData({
name: program.name,
description: program.description || '',
status: program.status,
})
}
}, [program])
const updateProgram = trpc.program.update.useMutation({
onSuccess: () => {
toast.success('Program updated successfully')
router.push(`/admin/programs/${id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to update program')
setIsSubmitting(false)
},
})
const deleteProgram = trpc.program.delete.useMutation({
onSuccess: () => {
toast.success('Program deleted successfully')
router.push('/admin/programs')
},
onError: (error) => {
toast.error(error.message || 'Failed to delete program')
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateProgram.mutate({
id,
name: formData.name,
description: formData.description || undefined,
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
})
}
// Delete handled via AlertDialog in JSX
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-48" />
<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-24 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/programs/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Edit Program</h1>
<p className="text-muted-foreground">
Update program information
</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Program</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this program and all its rounds and projects.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteProgram.mutate({ id })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle>Program Details</CardTitle>
<CardDescription>
Basic information about the program
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Program Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="ARCHIVED">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
maxLength={2000}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href={`/admin/programs/${id}`}>
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
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 { ArrowLeft, Pencil, Plus, Settings } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps {
params: Promise<{ id: string }>
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
DRAFT: 'secondary',
ACTIVE: 'default',
CLOSED: 'success',
ARCHIVED: 'secondary',
}
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
const { id } = await params
const caller = await api()
let program
try {
program = await caller.program.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/programs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{program.name}</h1>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</div>
<p className="text-muted-foreground">
{program.year} Edition
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</Button>
</div>
</div>
{program.description && (
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{program.description}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Voting rounds for this program
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?programId=${id}`}>
<Plus className="mr-2 h-4 w-4" />
New Round
</Link>
</Button>
</CardHeader>
<CardContent>
{program.rounds.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No rounds created yet. Create a round to start accepting projects.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
</TableCell>
<TableCell>
<Badge variant={statusColors[round.status] || 'secondary'}>
{round.status}
</Badge>
</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft } from 'lucide-react'
export default function ProgramSettingsPage() {
const params = useParams()
const id = params.id as string
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
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-48" />
<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" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href={`/admin/programs/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Program Settings</h1>
<p className="text-muted-foreground">
Configure settings for {program?.name}
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Program Configuration</CardTitle>
<CardDescription>
Advanced settings for this program
</CardDescription>
</CardHeader>
<CardContent>
<div className="py-8 text-center text-muted-foreground">
Program-specific settings will be available in a future update.
<br />
For now, manage rounds and projects through the program detail page.
</div>
<div className="flex justify-center">
<Button asChild>
<Link href={`/admin/programs/${id}`}>
Back to Program
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
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 { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewProgramPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createProgram = trpc.program.create.useMutation({
onSuccess: () => {
toast.success('Program created successfully')
router.push('/admin/programs')
},
onError: (error) => {
toast.error(error.message || 'Failed to create program')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const year = parseInt(formData.get('year') as string, 10)
const description = formData.get('description') as string
createProgram.mutate({
name,
year,
description: description || undefined,
})
}
const currentYear = new Date().getFullYear()
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/programs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Program</h1>
<p className="text-muted-foreground">
Set up a new ocean protection program
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Program Details</CardTitle>
<CardDescription>
Basic information about the program
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Program Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., Monaco Ocean Protection Challenge 2026"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year *</Label>
<Input
id="year"
name="year"
type="number"
min={2020}
max={2100}
defaultValue={currentYear}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the program objectives and scope..."
rows={4}
maxLength={2000}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/programs">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Program
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,258 @@
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,
FolderKanban,
Settings,
Eye,
Pencil,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() {
const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
include: {
_count: {
select: {
rounds: true,
},
},
rounds: {
where: { status: 'ACTIVE' },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
})
if (programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No programs yet</p>
<p className="text-sm text-muted-foreground">
Create your first program to start managing projects and rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">
<Plus className="mr-2 h-4 w-4" />
Create Program
</Link>
</Button>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
ACTIVE: 'default',
COMPLETED: 'success',
DRAFT: 'secondary',
ARCHIVED: 'secondary',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Year</TableHead>
<TableHead>Rounds</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programs.map((program) => (
<TableRow key={program.id}>
<TableCell>
<div>
<p className="font-medium">{program.name}</p>
{program.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{program.description}
</p>
)}
</div>
</TableCell>
<TableCell>{program.year}</TableCell>
<TableCell>
<div>
<p>{program._count.rounds} total</p>
{program.rounds.length > 0 && (
<p className="text-sm text-muted-foreground">
{program.rounds.length} active
</p>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</TableCell>
<TableCell>{formatDateOnly(program.createdAt)}</TableCell>
<TableCell className="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/programs/${program.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{programs.map((program) => (
<Card key={program.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{program.name}</CardTitle>
<CardDescription>{program.year}</CardDescription>
</div>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Rounds</span>
<span>
{program._count.rounds} ({program.rounds.length} active)
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{formatDateOnly(program.createdAt)}</span>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)
}
function ProgramsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function ProgramsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
<p className="text-muted-foreground">
Manage your ocean protection programs
</p>
</div>
<Button asChild>
<Link href="/admin/programs/new">
<Plus className="mr-2 h-4 w-4" />
New Program
</Link>
</Button>
</div>
{/* Content */}
<Suspense fallback={<ProgramsSkeleton />}>
<ProgramsContent />
</Suspense>
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,451 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
Download,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
PieChart,
TrendingUp,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
} from '@/components/charts'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: p.name }))) || []
if (isLoading) {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (!rounds || rounds.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Create rounds and assign jury members to generate reports
</p>
</CardContent>
</Card>
)
}
// Calculate totals
const totalProjects = programs?.reduce((acc, p) => acc + (p._count?.rounds || 0), 0) || 0
const totalPrograms = programs?.length || 0
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{activeRounds} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
</div>
{/* Rounds Table */}
<Card>
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardDescription>
View progress and export data for each round
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Export</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
<p className="font-medium">{round.name}</p>
{round.votingEndAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{round.programName}</TableCell>
<TableCell>-</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?roundId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Evaluations
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/results?roundId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Results
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
function RoundAnalytics() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: p.name }))) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId!, limit: 15 },
{ enabled: !!selectedRoundId }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
)
if (roundsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-64" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</div>
</div>
)
}
if (!rounds?.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
<p className="text-sm text-muted-foreground">
Create a round to view analytics
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Round Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRoundId && (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
{/* Row 6: Geographic Distribution */}
{geoLoading ? (
<Skeleton className="h-[500px]" />
) : geoData?.length ? (
<GeographicDistribution data={geoData} />
) : null}
</div>
)}
</div>
)
}
export default function ReportsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground">
View progress, analytics, and export evaluation data
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2">
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<ReportsOverview />
</TabsContent>
<TabsContent value="analytics">
<RoundAnalytics />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,545 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
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 { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
ArrowLeft,
Users,
FileText,
CheckCircle2,
Clock,
AlertCircle,
Sparkles,
Loader2,
Plus,
Trash2,
RefreshCw,
} from 'lucide-react'
interface PageProps {
params: Promise<{ id: string }>
}
function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery(
{ roundId, maxPerJuror: 10, minPerProject: 3 },
{ enabled: !!round }
)
const utils = trpc.useUtils()
const deleteAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
},
})
const applySuggestions = trpc.assignment.applySuggestions.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId })
setSelectedSuggestions(new Set())
},
})
if (loadingRound || loadingAssignments) {
return <AssignmentsSkeleton />
}
if (!round) {
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">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const handleToggleSuggestion = (key: string) => {
setSelectedSuggestions((prev) => {
const newSet = new Set(prev)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
return newSet
})
}
const handleSelectAllSuggestions = () => {
if (suggestions) {
if (selectedSuggestions.size === suggestions.length) {
setSelectedSuggestions(new Set())
} else {
setSelectedSuggestions(
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`))
)
}
}
}
const handleApplySelected = async () => {
if (!suggestions) return
const selected = suggestions.filter((s) =>
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
)
await applySuggestions.mutateAsync({
roundId,
assignments: selected.map((s) => ({
userId: s.userId,
projectId: s.projectId,
reasoning: s.reasoning.join('; '),
})),
})
}
// Group assignments by project
const assignmentsByProject = assignments?.reduce((acc, assignment) => {
const projectId = assignment.project.id
if (!acc[projectId]) {
acc[projectId] = {
project: assignment.project,
assignments: [],
}
}
acc[projectId].assignments.push(assignment)
return acc
}, {} as Record<string, { project: (typeof assignments)[0]['project'], assignments: typeof assignments }>) || {}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<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">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Manage Assignments
</h1>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedAssignments}</div>
<p className="text-xs text-muted-foreground">
{stats.completionPercentage}% complete
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects Covered</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.projectsWithFullCoverage}/{stats.totalProjects}
</div>
<p className="text-xs text-muted-foreground">
{stats.coveragePercentage}% have {round.requiredReviews}+ reviews
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.juryMembersAssigned}</div>
<p className="text-xs text-muted-foreground">assigned to projects</p>
</CardContent>
</Card>
</div>
)}
{/* Coverage Progress */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Coverage</CardTitle>
<CardDescription>
{stats.projectsWithFullCoverage} of {stats.totalProjects} projects have
at least {round.requiredReviews} reviewers assigned
</CardDescription>
</CardHeader>
<CardContent>
<Progress value={stats.coveragePercentage} className="h-3" />
</CardContent>
</Card>
)}
{/* Smart Suggestions */}
<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-amber-500" />
Smart Assignment Suggestions
</CardTitle>
<CardDescription>
AI-powered recommendations based on expertise matching and workload
balance
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetchSuggestions()}
disabled={loadingSuggestions}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{loadingSuggestions ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : suggestions && suggestions.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedSuggestions.size === suggestions.length}
onCheckedChange={handleSelectAllSuggestions}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={handleApplySelected}
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
>
{applySuggestions.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Apply Selected ({selectedSuggestions.size})
</Button>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Juror</TableHead>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Reasoning</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const key = `${suggestion.userId}-${suggestion.projectId}`
const isSelected = selectedSuggestions.has(key)
return (
<TableRow
key={key}
className={isSelected ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSuggestion(key)}
/>
</TableCell>
<TableCell className="font-medium">
{suggestion.userId.slice(0, 8)}...
</TableCell>
<TableCell>
{suggestion.projectId.slice(0, 8)}...
</TableCell>
<TableCell>
<Badge
variant={
suggestion.score >= 60
? 'default'
: suggestion.score >= 40
? 'secondary'
: 'outline'
}
>
{suggestion.score.toFixed(0)}
</Badge>
</TableCell>
<TableCell className="max-w-xs">
<ul className="text-xs text-muted-foreground">
{suggestion.reasoning.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
<p className="mt-2 font-medium">All projects are covered!</p>
<p className="text-sm text-muted-foreground">
No additional assignments are needed at this time
</p>
</div>
)}
</CardContent>
</Card>
{/* Current Assignments */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Assignments</CardTitle>
<CardDescription>
View and manage existing project assignments
</CardDescription>
</CardHeader>
<CardContent>
{Object.keys(assignmentsByProject).length > 0 ? (
<div className="space-y-6">
{Object.entries(assignmentsByProject).map(
([projectId, { project, assignments: projectAssignments }]) => (
<div key={projectId} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{project.title}</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{projectAssignments.length} reviewer
{projectAssignments.length !== 1 ? 's' : ''}
</Badge>
{projectAssignments.length >= round.requiredReviews && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Full coverage
</Badge>
)}
</div>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-2">
{projectAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-2">
<span className="text-sm">
{assignment.user.name || assignment.user.email}
</span>
{assignment.evaluation?.status === 'SUBMITTED' ? (
<Badge variant="default" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : assignment.evaluation?.status === 'DRAFT' ? (
<Badge variant="secondary" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
) : (
<Badge variant="outline" className="text-xs">
Pending
</Badge>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={
assignment.evaluation?.status === 'SUBMITTED'
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove Assignment?
</AlertDialogTitle>
<AlertDialogDescription>
This will remove {assignment.user.name || assignment.user.email} from
evaluating this project. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteAssignment.mutate({ id: assignment.id })
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
</div>
)
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Assignments Yet</p>
<p className="text-sm text-muted-foreground">
Use the smart suggestions above or manually assign jury members to
projects
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function AssignmentsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-64" />
</div>
<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-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function AssignmentManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentManagementContent roundId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,450 @@
'use client'
import { Suspense, use, useState, useEffect } 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 { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
EvaluationFormBuilder,
type Criterion,
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
import { format } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
const updateRoundSchema = z
.object({
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
votingStartAt: z.string().optional(),
votingEndAt: z.string().optional(),
})
.refine(
(data) => {
if (data.votingStartAt && data.votingEndAt) {
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
}
return true
},
{
message: 'End date must be after start date',
path: ['votingEndAt'],
}
)
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
// Convert ISO date to datetime-local format
function toDatetimeLocal(date: Date | string | null | undefined): string {
if (!date) return ''
const d = new Date(date)
// Format: YYYY-MM-DDTHH:mm
return format(d, "yyyy-MM-dd'T'HH:mm")
}
function EditRoundContent({ roundId }: { roundId: string }) {
const router = useRouter()
const [criteria, setCriteria] = useState<Criterion[]>([])
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
// Fetch round data
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
id: roundId,
})
// Fetch evaluation form
const { data: evaluationForm, isLoading: loadingForm } =
trpc.round.getEvaluationForm.useQuery({ roundId })
// Check if evaluations exist
const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({
roundId,
})
// Mutations
const updateRound = trpc.round.update.useMutation({
onSuccess: () => {
router.push(`/admin/rounds/${roundId}`)
},
})
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation()
// Initialize form with existing data
const form = useForm<UpdateRoundForm>({
resolver: zodResolver(updateRoundSchema),
defaultValues: {
name: '',
requiredReviews: 3,
votingStartAt: '',
votingEndAt: '',
},
})
// Update form when round data loads
useEffect(() => {
if (round) {
form.reset({
name: round.name,
requiredReviews: round.requiredReviews,
votingStartAt: toDatetimeLocal(round.votingStartAt),
votingEndAt: toDatetimeLocal(round.votingEndAt),
})
// Set round type and settings
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
}
}, [round, form])
// Initialize criteria from evaluation form
useEffect(() => {
if (evaluationForm && !criteriaInitialized) {
const existingCriteria = evaluationForm.criteriaJson as unknown as Criterion[]
if (Array.isArray(existingCriteria)) {
setCriteria(existingCriteria)
}
setCriteriaInitialized(true)
} else if (!loadingForm && !evaluationForm && !criteriaInitialized) {
setCriteriaInitialized(true)
}
}, [evaluationForm, loadingForm, criteriaInitialized])
const onSubmit = async (data: UpdateRoundForm) => {
// Update round with type and settings
await updateRound.mutateAsync({
id: roundId,
name: data.name,
requiredReviews: data.requiredReviews,
roundType,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
})
// Update evaluation form if criteria changed and no evaluations exist
if (!hasEvaluations && criteria.length > 0) {
await updateEvaluationForm.mutateAsync({
roundId,
criteria,
})
}
}
const isLoading = loadingRound || loadingForm
if (isLoading) {
return <EditRoundSkeleton />
}
if (!round) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</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">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const isPending = updateRound.isPending || updateEvaluationForm.isPending
const isActive = round.status === 'ACTIVE'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Edit Round</h1>
<Badge variant={isActive ? 'default' : 'secondary'}>
{round.status}
</Badge>
</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">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Round Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Round 1 - Semi-Finalists"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
/>
{/* Voting Window */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardDescription>
Set when jury members can submit their evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isActive && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 p-3 text-amber-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
<p className="text-sm">
This round is active. Changing the voting window may affect
ongoing evaluations.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="votingStartAt"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="votingEndAt"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to disable the voting window enforcement.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Define the criteria jurors will use to evaluate projects
</CardDescription>
</CardHeader>
<CardContent>
{hasEvaluations ? (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 p-3 text-amber-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
<p className="text-sm">
Criteria cannot be modified after evaluations have been
submitted. {criteria.length} criteria defined.
</p>
</div>
<EvaluationFormBuilder
initialCriteria={criteria}
onChange={() => {}}
disabled={true}
/>
</div>
) : (
<EvaluationFormBuilder
initialCriteria={criteria}
onChange={setCriteria}
/>
)}
</CardContent>
</Card>
{/* Error Display */}
{(updateRound.error || updateEvaluationForm.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">
{updateRound.error?.message ||
updateEvaluationForm.error?.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/admin/rounds/${roundId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</Form>
</div>
)
}
function EditRoundSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-6 w-16" />
</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-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function EditRoundPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<EditRoundSkeleton />}>
<EditRoundContent roundId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,537 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
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 { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import {
ArrowLeft,
Play,
Pause,
Square,
Clock,
Users,
Zap,
GripVertical,
AlertCircle,
ExternalLink,
RefreshCw,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
interface PageProps {
params: Promise<{ id: string }>
}
interface Project {
id: string
title: string
teamName: string | null
}
function SortableProject({
project,
isActive,
isVoting,
}: {
project: Project
isActive: boolean
isVoting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 rounded-lg border p-3 ${
isDragging ? 'opacity-50 shadow-lg' : ''
} ${isActive ? 'border-primary bg-primary/5' : ''} ${
isVoting ? 'ring-2 ring-green-500 animate-pulse' : ''
}`}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{isActive && (
<Badge variant={isVoting ? 'default' : 'secondary'}>
{isVoting ? 'Voting' : 'Current'}
</Badge>
)}
</div>
)
}
function LiveVotingContent({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const [projectOrder, setProjectOrder] = useState<string[]>([])
const [countdown, setCountdown] = useState<number | null>(null)
const [votingDuration, setVotingDuration] = useState(30)
// Fetch session data
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
// Mutations
const setOrder = trpc.liveVoting.setProjectOrder.useMutation({
onSuccess: () => {
toast.success('Project order updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const startVoting = trpc.liveVoting.startVoting.useMutation({
onSuccess: () => {
toast.success('Voting started')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const stopVoting = trpc.liveVoting.stopVoting.useMutation({
onSuccess: () => {
toast.success('Voting stopped')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const endSession = trpc.liveVoting.endSession.useMutation({
onSuccess: () => {
toast.success('Session ended')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Initialize project order
useEffect(() => {
if (sessionData) {
const storedOrder = (sessionData.projectOrderJson as string[]) || []
if (storedOrder.length > 0) {
setProjectOrder(storedOrder)
} else {
setProjectOrder(sessionData.round.projects.map((p) => p.id))
}
}
}, [sessionData])
// Countdown timer
useEffect(() => {
if (!sessionData?.votingEndsAt || sessionData.status !== 'IN_PROGRESS') {
setCountdown(null)
return
}
const updateCountdown = () => {
const remaining = new Date(sessionData.votingEndsAt!).getTime() - Date.now()
setCountdown(Math.max(0, Math.floor(remaining / 1000)))
}
updateCountdown()
const interval = setInterval(updateCountdown, 1000)
return () => clearInterval(interval)
}, [sessionData?.votingEndsAt, sessionData?.status])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = projectOrder.indexOf(active.id as string)
const newIndex = projectOrder.indexOf(over.id as string)
const newOrder = arrayMove(projectOrder, oldIndex, newIndex)
setProjectOrder(newOrder)
if (sessionData) {
setOrder.mutate({
sessionId: sessionData.id,
projectIds: newOrder,
})
}
}
}
const handleStartVoting = (projectId: string) => {
if (!sessionData) return
startVoting.mutate({
sessionId: sessionData.id,
projectId,
durationSeconds: votingDuration,
})
}
const handleStopVoting = () => {
if (!sessionData) return
stopVoting.mutate({ sessionId: sessionData.id })
}
const handleEndSession = () => {
if (!sessionData) return
endSession.mutate({ sessionId: sessionData.id })
}
if (isLoading) {
return <LiveVotingSkeleton />
}
if (!sessionData) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load session</AlertDescription>
</Alert>
)
}
const projects = sessionData.round.projects
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p)
// Add any projects not in the order
const missingProjects = projects.filter((p) => !projectOrder.includes(p.id))
const allProjects = [...sortedProjects, ...missingProjects]
const isVoting = sessionData.status === 'IN_PROGRESS'
const isCompleted = sessionData.status === 'COMPLETED'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<Zap className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">Live Voting</h1>
<Badge
variant={
isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'
}
>
{sessionData.status.replace('_', ' ')}
</Badge>
</div>
<p className="text-muted-foreground">
{sessionData.round.program.name} - {sessionData.round.name}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main control panel */}
<div className="lg:col-span-2 space-y-6">
{/* Voting status */}
{isVoting && (
<Card className="border-green-500 bg-green-500/10">
<CardContent className="py-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Currently Voting
</p>
<p className="text-xl font-semibold">
{projects.find((p) => p.id === sessionData.currentProjectId)?.title}
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{countdown !== null ? countdown : '--'}s
</div>
<p className="text-sm text-muted-foreground">remaining</p>
</div>
</div>
{countdown !== null && (
<Progress
value={(countdown / votingDuration) * 100}
className="mt-4"
/>
)}
<div className="flex gap-2 mt-4">
<Button
variant="destructive"
onClick={handleStopVoting}
disabled={stopVoting.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Stop Voting
</Button>
</div>
</CardContent>
</Card>
)}
{/* Project order */}
<Card>
<CardHeader>
<CardTitle>Presentation Order</CardTitle>
<CardDescription>
Drag to reorder projects. Click &quot;Start Voting&quot; to begin voting
for a project.
</CardDescription>
</CardHeader>
<CardContent>
{allProjects.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No finalist projects found for this round
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={allProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{allProjects.map((project) => (
<div key={project.id} className="flex items-center gap-2">
<SortableProject
project={project}
isActive={sessionData.currentProjectId === project.id}
isVoting={
isVoting &&
sessionData.currentProjectId === project.id
}
/>
<Button
size="sm"
variant="outline"
onClick={() => handleStartVoting(project.id)}
disabled={
isVoting ||
isCompleted ||
startVoting.isPending
}
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Controls */}
<Card>
<CardHeader>
<CardTitle>Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Voting Duration</label>
<div className="flex items-center gap-2">
<input
type="number"
min="10"
max="300"
value={votingDuration}
onChange={(e) =>
setVotingDuration(parseInt(e.target.value) || 30)
}
className="w-20 px-2 py-1 border rounded text-center"
disabled={isVoting}
/>
<span className="text-sm text-muted-foreground">seconds</span>
</div>
</div>
<div className="pt-4 border-t">
<Button
variant="destructive"
className="w-full"
onClick={handleEndSession}
disabled={isCompleted || endSession.isPending}
>
<Square className="mr-2 h-4 w-4" />
End Session
</Button>
</div>
</CardContent>
</Card>
{/* Live stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Current Votes
</CardTitle>
</CardHeader>
<CardContent>
{sessionData.currentVotes.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No votes yet
</p>
) : (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total votes</span>
<span className="font-medium">
{sessionData.currentVotes.length}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Average score</span>
<span className="font-medium">
{(
sessionData.currentVotes.reduce(
(sum, v) => sum + v.score,
0
) / sessionData.currentVotes.length
).toFixed(1)}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Voting Links</CardTitle>
<CardDescription>
Share these links with participants
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Jury Voting Page
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link
href={`/live-scores/${sessionData.id}`}
target="_blank"
>
<ExternalLink className="mr-2 h-4 w-4" />
Public Score Display
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
function LiveVotingSkeleton() {
return (
<div className="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" />
</div>
<div>
<Skeleton className="h-48 w-full" />
</div>
</div>
</div>
)
}
export default function LiveVotingPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<LiveVotingSkeleton />}>
<LiveVotingContent roundId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,418 @@
'use client'
import { Suspense, use } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
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 { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
AlertCircle,
Archive,
Play,
Pause,
BarChart3,
Upload,
} from 'lucide-react'
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
function RoundDetailContent({ roundId }: { roundId: string }) {
const router = useRouter()
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
},
})
if (isLoading) {
return <RoundDetailSkeleton />
}
if (!round) {
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">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const getStatusBadge = () => {
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<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">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
{getStatusBadge()}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{round.status === 'DRAFT' && (
<Button
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Activate
</Button>
)}
{round.status === 'ACTIVE' && (
<Button
variant="secondary"
onClick={() => updateStatus.mutate({ id: round.id, status: 'CLOSED' })}
disabled={updateStatus.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Close Round
</Button>
)}
</div>
</div>
<Separator />
{/* Stats Grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.assignments}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
Manage assignments
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round.requiredReviews}</div>
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{progress?.completionPercentage || 0}%
</div>
<p className="text-xs text-muted-foreground">
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
</p>
</CardContent>
</Card>
</div>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center justify-between text-sm mb-2">
<span>Overall Completion</span>
<span>{progress.completionPercentage}%</span>
</div>
<Progress value={progress.completionPercentage} />
</div>
<div className="grid gap-4 sm:grid-cols-4">
{Object.entries(progress.evaluationsByStatus).map(([status, count]) => (
<div key={status} className="text-center p-3 rounded-lg bg-muted">
<p className="text-2xl font-bold">{count}</p>
<p className="text-xs text-muted-foreground capitalize">
{status.toLowerCase().replace('_', ' ')}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Voting Window */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground mb-1">Start Date</p>
{round.votingStartAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingStartAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingStartAt), 'p')}
</p>
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">End Date</p>
{round.votingEndAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingEndAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingEndAt), 'p')}
</p>
{isFuture(new Date(round.votingEndAt)) && (
<p className="text-sm text-amber-600 mt-1">
Ends {formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}
</p>
)}
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
</div>
{/* Voting status */}
{round.votingStartAt && round.votingEndAt && (
<div
className={`p-4 rounded-lg ${
isVotingOpen
? 'bg-green-500/10 text-green-700'
: isFuture(new Date(round.votingStartAt))
? 'bg-amber-500/10 text-amber-700'
: 'bg-muted text-muted-foreground'
}`}
>
{isVotingOpen ? (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">Voting is currently open</span>
</div>
) : isFuture(new Date(round.votingStartAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">Voting period has ended</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<Link href={`/admin/projects/import?round=${round.id}`}>
<Upload className="mr-2 h-4 w-4" />
Import Projects
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/projects?round=${round.id}`}>
<FileText className="mr-2 h-4 w-4" />
View Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
function RoundDetailSkeleton() {
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" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</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" />
<Skeleton className="mt-1 h-4 w-20" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function RoundDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<RoundDetailSkeleton />}>
<RoundDetailContent roundId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,335 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } 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 { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
votingStartAt: z.string().optional(),
votingEndAt: z.string().optional(),
}).refine((data) => {
if (data.votingStartAt && data.votingEndAt) {
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
}
return true
}, {
message: 'End date must be after start date',
path: ['votingEndAt'],
})
type CreateRoundForm = z.infer<typeof createRoundSchema>
function CreateRoundContent() {
const router = useRouter()
const searchParams = useSearchParams()
const programIdParam = searchParams.get('program')
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
const createRound = trpc.round.create.useMutation({
onSuccess: (data) => {
router.push(`/admin/rounds/${data.id}`)
},
})
const form = useForm<CreateRoundForm>({
resolver: zodResolver(createRoundSchema),
defaultValues: {
programId: programIdParam || '',
name: '',
requiredReviews: 3,
votingStartAt: '',
votingEndAt: '',
},
})
const onSubmit = async (data: CreateRoundForm) => {
await createRound.mutateAsync({
programId: data.programId,
name: data.name,
requiredReviews: data.requiredReviews,
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
})
}
if (loadingPrograms) {
return <CreateRoundSkeleton />
}
if (!programs || programs.length === 0) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first before creating rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</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/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create Round</h1>
<p className="text-muted-foreground">
Set up a new selection round for project evaluation
</p>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="programId"
render={({ field }) => (
<FormItem>
<FormLabel>Program</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a program" />
</SelectTrigger>
</FormControl>
<SelectContent>
{programs.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} ({program.year})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Round Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Round 1 - Semi-Finalists"
{...field}
/>
</FormControl>
<FormDescription>
A descriptive name for this selection round
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardDescription>
Optional: Set when jury members can submit their evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="votingStartAt"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="votingEndAt"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to set the voting window later. The round will need to be
activated before jury members can submit evaluations.
</p>
</CardContent>
</Card>
{/* Error */}
{createRound.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">
{createRound.error.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href="/admin/rounds">Cancel</Link>
</Button>
<Button type="submit" disabled={createRound.isPending}>
{createRound.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Round
</Button>
</div>
</form>
</Form>
</div>
)
}
function CreateRoundSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-48" />
<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-40" />
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
</div>
)
}
export default function CreateRoundPage() {
return (
<Suspense fallback={<CreateRoundSkeleton />}>
<CreateRoundContent />
</Suspense>
)
}

View File

@@ -0,0 +1,347 @@
'use client'
import { Suspense } from 'react'
import Link from 'next/link'
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
Archive,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true,
})
if (isLoading) {
return <RoundsListSkeleton />
}
if (!programs || programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first to start managing rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{programs.map((program) => (
<Card key={program.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.name}</CardTitle>
<CardDescription>
{program.year} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{program.rounds && program.rounds.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<RoundRow key={round.id} round={round} />
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}
function RoundRow({ round }: { round: any }) {
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
},
})
const getStatusBadge = () => {
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground">Not set</span>
}
const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt)
const now = new Date()
if (isFuture(start)) {
return (
<span className="text-sm">
Opens {format(start, 'MMM d, yyyy')}
</span>
)
}
if (isPast(end)) {
return (
<span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d, yyyy')}
</span>
)
}
return (
<span className="text-sm">
Until {format(end, 'MMM d, yyyy')}
</span>
)
}
return (
<TableRow>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
</TableCell>
<TableCell>{getStatusBadge()}</TableCell>
<TableCell>{getVotingWindow()}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.projects || 0}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{round._count?.assignments || 0}
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
}
function RoundsListSkeleton() {
return (
<div className="space-y-6">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-10 w-28" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function RoundsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
<p className="text-muted-foreground">
Manage selection rounds and voting periods
</p>
</div>
</div>
{/* Content */}
<Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
async function SettingsLoader() {
const settings = await prisma.systemSettings.findMany({
orderBy: [{ category: 'asc' }, { key: 'asc' }],
})
// Convert settings array to key-value map
// For secrets, pass a marker but not the actual value
const settingsMap: Record<string, string> = {}
settings.forEach((setting) => {
if (setting.isSecret && setting.value) {
// Pass marker for UI to show "existing" state
settingsMap[setting.key] = '********'
} else {
settingsMap[setting.key] = setting.value
}
})
return <SettingsContent initialSettings={settingsMap} />
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-full" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default async function SettingsPage() {
const session = await auth()
// Only super admins can access settings
if (session?.user?.role !== 'SUPER_ADMIN') {
redirect('/admin')
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Configure platform settings and preferences
</p>
</div>
{/* Content */}
<Suspense fallback={<SettingsSkeleton />}>
<SettingsLoader />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,717 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
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 { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Plus,
MoreHorizontal,
Pencil,
Trash2,
Loader2,
Tags,
Users,
FolderKanban,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// Default categories
const DEFAULT_CATEGORIES = [
'Marine Science',
'Technology',
'Policy',
'Conservation',
'Business',
'Education',
'Engineering',
'Other',
]
// Default colors
const TAG_COLORS = [
{ value: '#de0f1e', label: 'Red' },
{ value: '#053d57', label: 'Dark Blue' },
{ value: '#557f8c', label: 'Teal' },
{ value: '#059669', label: 'Green' },
{ value: '#7c3aed', label: 'Purple' },
{ value: '#ea580c', label: 'Orange' },
{ value: '#0284c7', label: 'Blue' },
{ value: '#be185d', label: 'Pink' },
]
interface Tag {
id: string
name: string
description: string | null
category: string | null
color: string | null
isActive: boolean
sortOrder: number
userCount?: number
projectCount?: number
totalUsage?: number
}
function SortableTagRow({
tag,
onEdit,
onDelete,
}: {
tag: Tag
onEdit: (tag: Tag) => void
onDelete: (tag: Tag) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 rounded-lg border bg-card p-3 ${
isDragging ? 'opacity-50 shadow-lg' : ''
}`}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div
className="h-4 w-4 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{tag.name}</span>
{!tag.isActive && (
<Badge variant="secondary" className="text-xs">
Inactive
</Badge>
)}
</div>
{tag.category && (
<p className="text-xs text-muted-foreground">{tag.category}</p>
)}
</div>
<div className="hidden sm:flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1" title="Users with this tag">
<Users className="h-3.5 w-3.5" />
<span>{tag.userCount || 0}</span>
</div>
<div className="flex items-center gap-1" title="Projects with this tag">
<FolderKanban className="h-3.5 w-3.5" />
<span>{tag.projectCount || 0}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Tag actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(tag)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default function TagsSettingsPage() {
const utils = trpc.useUtils()
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null)
const [deletingTag, setDeletingTag] = useState<Tag | null>(null)
const [categoryFilter, setCategoryFilter] = useState<string>('all')
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [color, setColor] = useState('#557f8c')
const [isActive, setIsActive] = useState(true)
// Queries
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
includeUsageCount: true,
})
const { data: categories } = trpc.tag.getCategories.useQuery()
// Mutations
const createTag = trpc.tag.create.useMutation({
onSuccess: () => {
toast.success('Tag created successfully')
setIsCreateOpen(false)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const updateTag = trpc.tag.update.useMutation({
onSuccess: () => {
toast.success('Tag updated successfully')
setEditingTag(null)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const deleteTag = trpc.tag.delete.useMutation({
onSuccess: () => {
toast.success('Tag deleted successfully')
setDeletingTag(null)
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const reorderTags = trpc.tag.reorder.useMutation({
onError: (error) => {
toast.error(error.message)
utils.tag.list.invalidate()
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const resetForm = () => {
setName('')
setDescription('')
setCategory('')
setColor('#557f8c')
setIsActive(true)
}
const openEditDialog = (tag: Tag) => {
setEditingTag(tag)
setName(tag.name)
setDescription(tag.description || '')
setCategory(tag.category || '')
setColor(tag.color || '#557f8c')
setIsActive(tag.isActive)
}
const handleCreate = () => {
if (!name.trim()) {
toast.error('Please enter a tag name')
return
}
createTag.mutate({
name: name.trim(),
description: description || undefined,
category: category || undefined,
color: color || undefined,
})
}
const handleUpdate = () => {
if (!editingTag || !name.trim()) return
updateTag.mutate({
id: editingTag.id,
name: name.trim(),
description: description || null,
category: category || null,
color: color || null,
isActive,
})
}
const handleDelete = () => {
if (!deletingTag) return
deleteTag.mutate({ id: deletingTag.id })
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const tags = filteredTags
const oldIndex = tags.findIndex((t) => t.id === active.id)
const newIndex = tags.findIndex((t) => t.id === over.id)
const newOrder = arrayMove(tags, oldIndex, newIndex)
const items = newOrder.map((tag, index) => ({
id: tag.id,
sortOrder: index,
}))
reorderTags.mutate({ items })
}
}
// Filter tags by category
const filteredTags = (tagsData?.tags || []).filter((tag) => {
if (categoryFilter === 'all') return true
if (categoryFilter === 'uncategorized') return !tag.category
return tag.category === categoryFilter
})
// Get unique categories for filter
const allCategories = Array.from(
new Set([
...DEFAULT_CATEGORIES,
...(categories || []),
])
).sort()
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="h-8 w-64" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</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/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Settings
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Tags className="h-6 w-6" />
Expertise Tags
</h1>
<p className="text-muted-foreground">
Manage tags used for jury expertise and project categorization
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => resetForm()}>
<Plus className="mr-2 h-4 w-4" />
Add Tag
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Tag</DialogTitle>
<DialogDescription>
Add a new expertise tag for categorizing jury members and projects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Marine Biology"
/>
</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 this expertise area"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={createTag.isPending || !name.trim()}
>
{createTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Tag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Filter by Category</CardTitle>
</CardHeader>
<CardContent>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="uncategorized">Uncategorized</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Tags List */}
<Card>
<CardHeader>
<CardTitle>Tags ({filteredTags.length})</CardTitle>
<CardDescription>
Drag to reorder tags. Changes are saved automatically.
</CardDescription>
</CardHeader>
<CardContent>
{filteredTags.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Tags className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No tags found</p>
<Button
variant="link"
onClick={() => setIsCreateOpen(true)}
className="mt-2"
>
Create your first tag
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredTags.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{filteredTags.map((tag) => (
<SortableTagRow
key={tag.id}
tag={tag}
onEdit={openEditDialog}
onDelete={setDeletingTag}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog
open={!!editingTag}
onOpenChange={(open) => !open && setEditingTag(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tag</DialogTitle>
<DialogDescription>
Update this expertise tag. Renaming will update all users and projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name *</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="edit-category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="edit-color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="edit-active">Active</Label>
<p className="text-sm text-muted-foreground">
Inactive tags won&apos;t appear in selection lists
</p>
</div>
<Switch
id="edit-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingTag(null)}>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={updateTag.isPending || !name.trim()}
>
{updateTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deletingTag}
onOpenChange={(open) => !open && setDeletingTag(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deletingTag?.name}&quot;? This will
remove the tag from {deletingTag?.userCount || 0} users and{' '}
{deletingTag?.projectCount || 0} projects.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,316 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
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 {
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import { TagInput } from '@/components/shared/tag-input'
import {
ArrowLeft,
Save,
Mail,
User,
Shield,
Loader2,
CheckCircle,
AlertCircle,
} from 'lucide-react'
export default function UserEditPage() {
const params = useParams()
const router = useRouter()
const userId = params.id as string
const { data: user, isLoading, refetch } = trpc.user.get.useQuery({ id: userId })
const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN'>('JURY_MEMBER')
const [status, setStatus] = useState<'INVITED' | 'ACTIVE' | 'SUSPENDED'>('INVITED')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [maxAssignments, setMaxAssignments] = useState<string>('')
// Populate form when user data loads
useEffect(() => {
if (user) {
setName(user.name || '')
setRole(user.role as 'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN')
setStatus(user.status as 'INVITED' | 'ACTIVE' | 'SUSPENDED')
setExpertiseTags(user.expertiseTags || [])
setMaxAssignments(user.maxAssignments?.toString() || '')
}
}, [user])
const handleSave = async () => {
try {
await updateUser.mutateAsync({
id: userId,
name: name || null,
role,
status,
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
toast.success('User updated successfully')
router.push('/admin/users')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update user')
}
}
const handleSendInvitation = async () => {
try {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-32" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
if (!user) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>User not found</AlertTitle>
<AlertDescription>
The user you&apos;re looking for does not exist.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</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/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit User</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
{user.status === 'INVITED' && (
<Button
variant="outline"
onClick={handleSendInvitation}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Invitation
</Button>
)}
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Basic Information
</CardTitle>
<CardDescription>
Update the user&apos;s profile information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email cannot be changed
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as typeof role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Assignment Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Assignment Settings
</CardTitle>
<CardDescription>
Configure expertise tags and assignment limits
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
<p className="text-xs text-muted-foreground">
Maximum number of projects this user can be assigned
</p>
</div>
{user._count && (
<div className="pt-4 border-t">
<h4 className="font-medium mb-2">Statistics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Total Assignments</p>
<p className="text-2xl font-semibold">{user._count.assignments}</p>
</div>
<div>
<p className="text-muted-foreground">Last Login</p>
<p className="text-lg">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Status Alert */}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This user hasn&apos;t accepted their invitation yet. You can resend the
invitation email using the button above.
</AlertDescription>
</Alert>
)}
{/* Save Button */}
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/admin/users">Cancel</Link>
</Button>
<Button onClick={handleSave} disabled={updateUser.isPending}>
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,676 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
ArrowRight,
AlertCircle,
CheckCircle2,
Loader2,
Upload,
Users,
X,
Mail,
FileSpreadsheet,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface ParsedUser {
email: string
name?: string
isValid: boolean
error?: string
isDuplicate?: boolean
}
// Email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default function UserInvitePage() {
const router = useRouter()
const [step, setStep] = useState<Step>('input')
// Input state
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
const [emailsText, setEmailsText] = useState('')
const [csvFile, setCsvFile] = useState<File | null>(null)
const [role, setRole] = useState<Role>('JURY_MEMBER')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [tagInput, setTagInput] = useState('')
// Parsed users
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
// Send progress
const [sendProgress, setSendProgress] = useState(0)
// Result
const [result, setResult] = useState<{ created: number; skipped: number } | null>(null)
// Mutation
const bulkCreate = trpc.user.bulkCreate.useMutation()
// Parse emails from textarea
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
const lines = text
.split(/[\n,;]+/)
.map((line) => line.trim())
.filter(Boolean)
const seenEmails = new Set<string>()
return lines.map((line) => {
// Try to extract name and email like "Name <email@example.com>" or just "email@example.com"
const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/)
const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase()
const name = matchWithName ? matchWithName[1].trim() : undefined
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
if (isValidFormat && !isDuplicate) {
seenEmails.add(email)
}
const isValid = isValidFormat && !isDuplicate
return {
email,
name,
isValid,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: undefined,
}
})
}, [])
// Parse CSV file
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setCsvFile(file)
Papa.parse<Record<string, string>>(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
const seenEmails = new Set<string>()
const users: ParsedUser[] = results.data.map((row) => {
// Try to find email column (case-insensitive)
const emailKey = Object.keys(row).find(
(key) =>
key.toLowerCase() === 'email' ||
key.toLowerCase().includes('email')
)
const nameKey = Object.keys(row).find(
(key) =>
key.toLowerCase() === 'name' ||
key.toLowerCase().includes('name')
)
const email = emailKey ? row[emailKey]?.trim().toLowerCase() : ''
const name = nameKey ? row[nameKey]?.trim() : undefined
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
if (isValidFormat && !isDuplicate && email) {
seenEmails.add(email)
}
const isValid = isValidFormat && !isDuplicate
return {
email,
name,
isValid,
isDuplicate,
error: !email
? 'No email found'
: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: undefined,
}
})
setParsedUsers(users.filter((u) => u.email))
setStep('preview')
},
error: (error) => {
console.error('CSV parse error:', error)
},
})
},
[]
)
// Handle text input and proceed to preview
const handleTextProceed = () => {
const users = parseEmailsFromText(emailsText)
setParsedUsers(users)
setStep('preview')
}
// Add expertise tag
const addTag = () => {
const tag = tagInput.trim()
if (tag && !expertiseTags.includes(tag)) {
setExpertiseTags([...expertiseTags, tag])
setTagInput('')
}
}
// Remove expertise tag
const removeTag = (tag: string) => {
setExpertiseTags(expertiseTags.filter((t) => t !== tag))
}
// Summary stats
const summary = useMemo(() => {
const validUsers = parsedUsers.filter((u) => u.isValid)
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate)
return {
total: parsedUsers.length,
valid: validUsers.length,
invalid: invalidUsers.length,
duplicates: duplicateUsers.length,
validUsers,
invalidUsers,
duplicateUsers,
}
}, [parsedUsers])
// Remove invalid users
const removeInvalidUsers = () => {
setParsedUsers(parsedUsers.filter((u) => u.isValid))
}
// Send invites
const handleSendInvites = async () => {
if (summary.valid === 0) return
setStep('sending')
setSendProgress(0)
try {
const result = await bulkCreate.mutateAsync({
users: summary.validUsers.map((u) => ({
email: u.email,
name: u.name,
role,
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
})),
})
setSendProgress(100)
setResult(result)
setStep('complete')
} catch (error) {
console.error('Bulk create failed:', error)
setStep('preview')
}
}
// Reset form
const resetForm = () => {
setStep('input')
setEmailsText('')
setCsvFile(null)
setParsedUsers([])
setResult(null)
setSendProgress(0)
}
// Steps indicator
const steps: Array<{ key: Step; label: string }> = [
{ key: 'input', label: 'Input' },
{ key: 'preview', label: 'Preview' },
{ key: 'sending', label: 'Send' },
{ key: 'complete', label: 'Done' },
]
const currentStepIndex = steps.findIndex((s) => s.key === step)
const renderStep = () => {
switch (step) {
case 'input':
return (
<Card>
<CardHeader>
<CardTitle>Invite Users</CardTitle>
<CardDescription>
Add email addresses to invite new jury members or observers
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Input Method Toggle */}
<div className="flex gap-2">
<Button
type="button"
variant={inputMethod === 'textarea' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMethod('textarea')}
>
<Mail className="mr-2 h-4 w-4" />
Enter Emails
</Button>
<Button
type="button"
variant={inputMethod === 'csv' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMethod('csv')}
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Upload CSV
</Button>
</div>
{/* Input Area */}
{inputMethod === 'textarea' ? (
<div className="space-y-2">
<Label htmlFor="emails">Email Addresses</Label>
<Textarea
id="emails"
value={emailsText}
onChange={(e) => setEmailsText(e.target.value)}
placeholder="Enter email addresses, one per line or comma-separated.
You can also use format: Name <email@example.com>"
rows={8}
maxLength={10000}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
One email per line, or separated by commas
</p>
</div>
) : (
<div className="space-y-2">
<Label>CSV File</Label>
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
'hover:border-primary/50'
)}
onClick={() => document.getElementById('csv-input')?.click()}
>
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{csvFile ? csvFile.name : 'Drop CSV file here or click to browse'}
</p>
<p className="text-sm text-muted-foreground">
CSV should have an &quot;email&quot; column, optionally a &quot;name&quot; column
</p>
<Input
id="csv-input"
type="file"
accept=".csv"
onChange={handleCSVUpload}
className="hidden"
/>
</div>
</div>
)}
{/* Role Selection */}
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{role === 'JURY_MEMBER'
? 'Can evaluate assigned projects'
: role === 'MENTOR'
? 'Can view and mentor assigned projects'
: 'Read-only access to dashboards'}
</p>
</div>
{/* Expertise Tags */}
<div className="space-y-2">
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
<div className="flex gap-2">
<Input
id="expertise"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="e.g., Marine Biology"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
Add
</Button>
</div>
{expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{expertiseTags.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>
)}
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button variant="outline" asChild>
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Link>
</Button>
<Button
onClick={handleTextProceed}
disabled={inputMethod === 'textarea' && !emailsText.trim()}
>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
case 'preview':
return (
<Card>
<CardHeader>
<CardTitle>Preview Invitations</CardTitle>
<CardDescription>
Review the list of users to invite
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg bg-muted p-4 text-center">
<p className="text-3xl font-bold">{summary.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
<div className="rounded-lg bg-green-500/10 p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{summary.valid}
</p>
<p className="text-sm text-muted-foreground">Valid</p>
</div>
<div className="rounded-lg bg-red-500/10 p-4 text-center">
<p className="text-3xl font-bold text-red-600">
{summary.invalid}
</p>
<p className="text-sm text-muted-foreground">Invalid</p>
</div>
</div>
{/* Invalid users warning */}
{summary.invalid > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium">
{summary.invalid} email(s) have issues
</p>
<p className="text-sm mt-1">
{summary.duplicates > 0 && (
<span>{summary.duplicates} duplicate(s). </span>
)}
{summary.invalid - summary.duplicates > 0 && (
<span>{summary.invalid - summary.duplicates} invalid format(s). </span>
)}
These will be excluded from the invitation.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={removeInvalidUsers}
className="shrink-0"
>
Remove Invalid
</Button>
</div>
)}
{/* Settings Summary */}
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-muted-foreground">Role:</span>{' '}
<Badge variant="outline">{role.replace('_', ' ')}</Badge>
</div>
{expertiseTags.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tags:</span>
{expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Users Table */}
<div className="rounded-lg border max-h-80 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsedUsers.map((user, index) => (
<TableRow
key={index}
className={cn(!user.isValid && 'bg-red-500/5')}
>
<TableCell className="font-mono text-sm">
{user.email}
</TableCell>
<TableCell>{user.name || '-'}</TableCell>
<TableCell>
{user.isValid ? (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Valid
</Badge>
) : (
<Badge variant="destructive">{user.error}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button
variant="outline"
onClick={() => {
setParsedUsers([])
setStep('input')
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleSendInvites}
disabled={summary.valid === 0 || bulkCreate.isPending}
>
{bulkCreate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Users className="mr-2 h-4 w-4" />
)}
Create {summary.valid} User{summary.valid !== 1 ? 's' : ''}
</Button>
</div>
{/* Error */}
{bulkCreate.error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>{bulkCreate.error.message}</span>
</div>
)}
</CardContent>
</Card>
)
case 'sending':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">Creating users...</p>
<p className="text-sm text-muted-foreground">
Please wait while we process your request
</p>
<Progress value={sendProgress} className="mt-4 w-48" />
</CardContent>
</Card>
)
case 'complete':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<p className="mt-4 text-xl font-semibold">Users Created!</p>
<p className="text-muted-foreground text-center max-w-sm mt-2">
{result?.created} user{result?.created !== 1 ? 's' : ''} created
successfully.
{result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>
<Link href="/admin/users">View Users</Link>
</Button>
<Button onClick={resetForm}>Invite More</Button>
</div>
</CardContent>
</Card>
)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Invite Users</h1>
<p className="text-muted-foreground">
Add new jury members or observers to the platform
</p>
</div>
{/* Progress indicator */}
<div className="flex items-center justify-center gap-2">
{steps.map((s, index) => (
<div key={s.key} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 mx-1',
index <= currentStepIndex ? 'bg-primary' : 'bg-muted'
)}
/>
)}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
index === currentStepIndex
? 'bg-primary text-primary-foreground'
: index < currentStepIndex
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{index + 1}
</div>
</div>
))}
</div>
{renderStep()}
</div>
)
}

View File

@@ -0,0 +1,280 @@
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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, Users } from 'lucide-react'
import { formatDate, getInitials } from '@/lib/utils'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
async function UsersContent() {
const users = await prisma.user.findMany({
where: {
role: { in: ['JURY_MEMBER', 'OBSERVER'] },
},
include: {
_count: {
select: {
assignments: true,
},
},
assignments: {
select: {
evaluation: {
select: { status: true },
},
},
},
},
orderBy: [{ role: 'asc' }, { name: 'asc' }],
})
if (users.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No jury members yet</p>
<p className="text-sm text-muted-foreground">
Invite jury members to start assigning projects for evaluation
</p>
<Button asChild className="mt-4">
<Link href="/admin/users/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
</Link>
</Button>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
ACTIVE: 'success',
PENDING: 'secondary',
INACTIVE: 'secondary',
SUSPENDED: 'destructive',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
OBSERVER: 'outline',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{user.expertiseTags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{user.expertiseTags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{user.expertiseTags.length - 2}
</Badge>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div>
<p>{user._count.assignments} assigned</p>
<p className="text-sm text-muted-foreground">
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length} completed
</p>
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
</Badge>
</TableCell>
<TableCell>
{user.lastLoginAt ? (
formatDate(user.lastLoginAt)
) : (
<span className="text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell className="text-right">
<UserActions
userId={user.id}
userEmail={user.email}
userStatus={user.status}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{users.map((user) => (
<Card key={user.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">
{user.name || 'Unnamed'}
</CardTitle>
<CardDescription className="text-xs">
{user.email}
</CardDescription>
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length}/{user._count.assignments} completed
</span>
</div>
{user.expertiseTags && user.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{user.expertiseTags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<UserMobileActions
userId={user.id}
userEmail={user.email}
userStatus={user.status}
/>
</CardContent>
</Card>
))}
</div>
</>
)
}
function UsersSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function UsersPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Jury Members</h1>
<p className="text-muted-foreground">
Manage jury members and observers
</p>
</div>
<Button asChild>
<Link href="/admin/users/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
</Link>
</Button>
</div>
{/* Content */}
<Suspense fallback={<UsersSkeleton />}>
<UsersContent />
</Suspense>
</div>
)
}

55
src/app/(admin)/error.tsx Normal file
View File

@@ -0,0 +1,55 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, LayoutDashboard } from 'lucide-react'
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Admin section error:', error)
}, [error])
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
An error occurred while loading this admin page. Please try again or
return to the dashboard.
</p>
<div className="flex justify-center gap-2">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/admin">
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect'
import { AdminSidebar } from '@/components/layouts/admin-sidebar'
import { AdminEditionWrapper } from '@/components/layouts/admin-edition-wrapper'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
// Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({
select: {
id: true,
name: true,
year: true,
status: true,
},
orderBy: { year: 'desc' },
})
return (
<AdminEditionWrapper editions={editions}>
<div className="min-h-screen bg-background">
<AdminSidebar
user={{
name: session.user.name,
email: session.user.email,
role: session.user.role,
}}
/>
<main className="lg:pl-64">
{/* Spacer for mobile header */}
<div className="h-16 lg:hidden" />
<div className="container-app py-6 lg:py-8">{children}</div>
</main>
</div>
</AdminEditionWrapper>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AlertCircle } from 'lucide-react'
const errorMessages: Record<string, string> = {
Configuration: 'There is a problem with the server configuration.',
AccessDenied: 'You do not have access to this resource.',
Verification: 'The verification link has expired or already been used.',
Default: 'An error occurred during authentication.',
}
export default function AuthErrorPage() {
const searchParams = useSearchParams()
const error = searchParams.get('error') || 'Default'
const message = errorMessages[error] || errorMessages.Default
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-xl">Authentication Error</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">{message}</p>
<div className="border-t pt-4">
<Button asChild>
<Link href="/login">Try again</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

53
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { redirect } from 'next/navigation'
import Image from 'next/image'
import { auth } from '@/lib/auth'
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
// Redirect logged-in users to their dashboard
if (session?.user) {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
} else if (role === 'JURY_MEMBER') {
redirect('/jury')
} else if (role === 'OBSERVER') {
redirect('/observer')
}
}
return (
<div className="min-h-screen flex flex-col">
{/* Simple header with logo */}
<header className="border-b bg-card">
<div className="container-app py-4">
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC - Monaco Ocean Protection Challenge"
width={160}
height={50}
className="h-12 w-auto"
priority
/>
</div>
</header>
{/* Main content */}
<main className="flex-1 flex items-center justify-center p-4">
{children}
</main>
{/* Simple footer */}
<footer className="border-t bg-card py-4">
<div className="container-app text-center text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Monaco Ocean Protection Challenge
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Sign In' }
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,290 @@
'use client'
import { useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
type LoginMode = 'password' | 'magic-link'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [mode, setMode] = useState<LoginMode>('password')
const [isLoading, setIsLoading] = useState(false)
const [isSent, setIsSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const searchParams = useSearchParams()
const router = useRouter()
const callbackUrl = searchParams.get('callbackUrl') || '/'
const errorParam = searchParams.get('error')
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
callbackUrl,
})
if (result?.error) {
setError('Invalid email or password. Please try again.')
} else if (result?.ok) {
// Use window.location for external redirects or callback URLs
window.location.href = callbackUrl
}
} catch {
setError('An unexpected error occurred. Please try again.')
} finally {
setIsLoading(false)
}
}
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
// Get CSRF token first
const csrfRes = await fetch('/api/auth/csrf')
const { csrfToken } = await csrfRes.json()
// POST directly to the signin endpoint
const res = await fetch('/api/auth/signin/email', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
csrfToken,
email,
callbackUrl,
}),
redirect: 'manual',
})
// 302 redirect means success
if (res.type === 'opaqueredirect' || res.status === 302 || res.ok) {
setIsSent(true)
} else {
setError('Failed to send magic link. Please try again.')
}
} catch {
setError('An unexpected error occurred. Please try again.')
} finally {
setIsLoading(false)
}
}
// Success state after sending magic link
if (isSent) {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
<CardDescription className="text-base">
We&apos;ve sent a magic link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
Click the link in the email to sign in. The link will expire in 15
minutes.
</p>
<div className="border-t pt-4">
<Button
variant="ghost"
className="w-full"
onClick={() => {
setIsSent(false)
setEmail('')
setPassword('')
}}
>
Use a different email
</Button>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
{mode === 'password'
? 'Sign in with your email and password'
: 'Sign in with a magic link'}
</CardDescription>
</CardHeader>
<CardContent>
{mode === 'password' ? (
// Password login form
<form onSubmit={handlePasswordLogin} className="space-y-4">
{(error || errorParam) && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>
{error ||
(errorParam === 'Verification'
? 'The magic link has expired or is invalid.'
: errorParam === 'CredentialsSignin'
? 'Invalid email or password.'
: 'An error occurred during sign in.')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
autoComplete="email"
autoFocus
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
className="text-sm text-muted-foreground hover:text-primary transition-colors"
onClick={() => {
setMode('magic-link')
setError(null)
}}
>
Forgot password?
</button>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
<>
<Lock className="mr-2 h-4 w-4" />
Sign in
</>
)}
</Button>
</form>
) : (
// Magic link form
<form onSubmit={handleMagicLink} className="space-y-4">
{(error || errorParam) && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>
{error ||
(errorParam === 'Verification'
? 'The magic link has expired or is invalid.'
: 'An error occurred during sign in.')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email-magic">Email address</Label>
<Input
id="email-magic"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
autoComplete="email"
autoFocus
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Send magic link
</>
)}
</Button>
<p className="text-sm text-muted-foreground text-center">
We&apos;ll send you a secure link to sign in or reset your
password.
</p>
</form>
)}
{/* Toggle between modes */}
<div className="mt-6 border-t pt-4">
<button
type="button"
className="w-full flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors"
onClick={() => {
setMode(mode === 'password' ? 'magic-link' : 'password')
setError(null)
}}
>
{mode === 'password' ? (
<>
<KeyRound className="h-4 w-4" />
Use magic link instead
</>
) : (
<>
<Lock className="h-4 w-4" />
Sign in with password
</>
)}
</button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,330 @@
'use client'
import { useState } from 'react'
import { useRouter } 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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import { TagInput } from '@/components/shared/tag-input'
import {
User,
Phone,
Tags,
Bell,
CheckCircle,
Loader2,
ArrowRight,
ArrowLeft,
} from 'lucide-react'
type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete'
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<Step>('name')
// Form state
const [name, setName] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [notificationPreference, setNotificationPreference] = useState<
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
const completeOnboarding = trpc.user.completeOnboarding.useMutation()
const steps: Step[] = ['name', 'phone', 'tags', 'preferences', 'complete']
const currentIndex = steps.indexOf(step)
const goNext = () => {
if (step === 'name' && !name.trim()) {
toast.error('Please enter your name')
return
}
const nextIndex = currentIndex + 1
if (nextIndex < steps.length) {
setStep(steps[nextIndex])
}
}
const goBack = () => {
const prevIndex = currentIndex - 1
if (prevIndex >= 0) {
setStep(steps[prevIndex])
}
}
const handleComplete = async () => {
try {
await completeOnboarding.mutateAsync({
name,
phoneNumber: phoneNumber || undefined,
expertiseTags,
notificationPreference,
})
setStep('complete')
toast.success('Welcome to MOPC!')
// Redirect after a short delay
setTimeout(() => {
router.push('/jury')
}, 2000)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to complete onboarding')
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-lg">
{/* Progress indicator */}
<div className="px-6 pt-6">
<div className="flex items-center gap-2">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex items-center flex-1">
<div
className={`h-2 flex-1 rounded-full transition-colors ${
i < currentIndex
? 'bg-primary'
: i === currentIndex
? 'bg-primary/60'
: 'bg-muted'
}`}
/>
</div>
))}
</div>
<p className="text-sm text-muted-foreground mt-2">
Step {currentIndex + 1} of {steps.length - 1}
</p>
</div>
{/* Step 1: Name */}
{step === 'name' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
Welcome to MOPC
</CardTitle>
<CardDescription>
Let&apos;s get your profile set up. What should we call you?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name"
autoFocus
/>
</div>
<Button onClick={goNext} className="w-full" disabled={!name.trim()}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</>
)}
{/* Step 2: Phone */}
{step === 'phone' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="h-5 w-5 text-primary" />
Contact Information
</CardTitle>
<CardDescription>
Optionally add your phone number for WhatsApp notifications
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number (Optional)</Label>
<Input
id="phone"
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+377 12 34 56 78"
/>
<p className="text-xs text-muted-foreground">
Include country code for WhatsApp notifications
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 3: Tags */}
{step === 'tags' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-primary" />
Your Expertise
</CardTitle>
<CardDescription>
Select tags that describe your areas of expertise. This helps us match you with relevant projects.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select your expertise areas..."
maxTags={10}
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 4: Preferences */}
{step === 'preferences' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Notification Preferences
</CardTitle>
<CardDescription>
How would you like to receive notifications?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="notifications">Notification Channel</Label>
<Select
value={notificationPreference}
onValueChange={(v) =>
setNotificationPreference(v as typeof notificationPreference)
}
>
<SelectTrigger id="notifications">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">Email only</SelectItem>
<SelectItem value="WHATSAPP" disabled={!phoneNumber}>
WhatsApp only
</SelectItem>
<SelectItem value="BOTH" disabled={!phoneNumber}>
Both Email and WhatsApp
</SelectItem>
<SelectItem value="NONE">No notifications</SelectItem>
</SelectContent>
</Select>
{!phoneNumber && (
<p className="text-xs text-muted-foreground">
Add a phone number to enable WhatsApp notifications
</p>
)}
</div>
<div className="rounded-lg border p-4 bg-muted/50">
<h4 className="font-medium mb-2">Summary</h4>
<div className="space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Name:</span> {name}
</p>
{phoneNumber && (
<p>
<span className="text-muted-foreground">Phone:</span>{' '}
{phoneNumber}
</p>
)}
<p>
<span className="text-muted-foreground">Expertise:</span>{' '}
{expertiseTags.length > 0
? expertiseTags.join(', ')
: 'None selected'}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleComplete}
className="flex-1"
disabled={completeOnboarding.isPending}
>
{completeOnboarding.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle className="mr-2 h-4 w-4" />
)}
Complete Setup
</Button>
</div>
</CardContent>
</>
)}
{/* Step 5: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-green-100 p-4 mb-4">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
<h2 className="text-xl font-semibold mb-2">Welcome, {name}!</h2>
<p className="text-muted-foreground text-center mb-4">
Your profile is all set up. You&apos;ll be redirected to your dashboard
shortly.
</p>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</CardContent>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,297 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
export default function SetPasswordPage() {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSuccess, setIsSuccess] = useState(false)
const router = useRouter()
const { data: session, update: updateSession } = useSession()
const setPasswordMutation = trpc.user.setPassword.useMutation({
onSuccess: async () => {
setIsSuccess(true)
// Update the session to reflect the password has been set
await updateSession()
// Redirect after a short delay
setTimeout(() => {
if (session?.user?.role === 'JURY_MEMBER') {
router.push('/jury')
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else {
router.push('/')
}
}, 2000)
},
onError: (err) => {
setError(err.message || 'Failed to set password. Please try again.')
setIsLoading(false)
},
})
// Redirect if not authenticated
useEffect(() => {
if (session === null) {
router.push('/login')
}
}, [session, router])
// Password validation
const validatePassword = (pwd: string) => {
const errors: string[] = []
if (pwd.length < 8) errors.push('At least 8 characters')
if (!/[A-Z]/.test(pwd)) errors.push('One uppercase letter')
if (!/[a-z]/.test(pwd)) errors.push('One lowercase letter')
if (!/[0-9]/.test(pwd)) errors.push('One number')
return errors
}
const passwordErrors = validatePassword(password)
const isPasswordValid = passwordErrors.length === 0
const doPasswordsMatch = password === confirmPassword && password.length > 0
// Password strength
const getPasswordStrength = (pwd: string): { score: number; label: string; color: string } => {
let score = 0
if (pwd.length >= 8) score++
if (pwd.length >= 12) score++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
if (/[0-9]/.test(pwd)) score++
if (/[^a-zA-Z0-9]/.test(pwd)) score++
const normalizedScore = Math.min(4, score)
const labels = ['Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong']
const colors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-green-600']
return {
score: normalizedScore,
label: labels[normalizedScore],
color: colors[normalizedScore],
}
}
const strength = getPasswordStrength(password)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!isPasswordValid) {
setError('Password does not meet requirements.')
return
}
if (!doPasswordsMatch) {
setError('Passwords do not match.')
return
}
setIsLoading(true)
setPasswordMutation.mutate({ password, confirmPassword })
}
// Loading state while checking session
if (session === undefined) {
return (
<Card className="w-full max-w-md">
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
// Success state
if (isSuccess) {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Password Set Successfully</CardTitle>
<CardDescription>
Your password has been set. You can now sign in with your email and
password.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground">
Redirecting you to the dashboard...
</p>
</CardContent>
</Card>
)
}
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Lock className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl">Set Your Password</CardTitle>
<CardDescription>
Create a secure password to sign in to your account in the future.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter a secure password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="new-password"
autoFocus
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{/* Password strength indicator */}
{password.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Progress
value={(strength.score / 4) * 100}
className={`h-2 ${strength.color}`}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{strength.label}
</span>
</div>
{/* Requirements checklist */}
<div className="grid grid-cols-2 gap-1 text-xs">
{[
{ label: '8+ characters', met: password.length >= 8 },
{ label: 'Uppercase', met: /[A-Z]/.test(password) },
{ label: 'Lowercase', met: /[a-z]/.test(password) },
{ label: 'Number', met: /[0-9]/.test(password) },
].map((req) => (
<div
key={req.label}
className={`flex items-center gap-1 ${
req.met ? 'text-green-600' : 'text-muted-foreground'
}`}
>
{req.met ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<div className="h-3 w-3 rounded-full border border-current" />
)}
{req.label}
</div>
))}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{confirmPassword.length > 0 && (
<p
className={`text-xs ${
doPasswordsMatch ? 'text-green-600' : 'text-destructive'
}`}
>
{doPasswordsMatch
? 'Passwords match'
: 'Passwords do not match'}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !isPasswordValid || !doPasswordsMatch}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Setting password...
</>
) : (
<>
<Lock className="mr-2 h-4 w-4" />
Set Password
</>
)}
</Button>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,27 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Mail } from 'lucide-react'
export default function VerifyEmailPage() {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-teal/10">
<Mail className="h-6 w-6 text-brand-teal" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
<CardDescription className="text-base">
We&apos;ve sent you a magic link to sign in
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-sm text-muted-foreground">
Click the link in your email to complete the sign-in process.
The link will expire in 15 minutes.
</p>
<p className="text-xs text-muted-foreground">
Didn&apos;t receive an email? Check your spam folder or try again.
</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle2 } from 'lucide-react'
export default function VerifyPage() {
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
A sign-in link has been sent to your email address. Click the link to
complete your sign in.
</p>
<div className="border-t pt-4">
<Button variant="outline" asChild>
<Link href="/login">Back to login</Link>
</Button>
</div>
</CardContent>
</Card>
)
}

55
src/app/(jury)/error.tsx Normal file
View File

@@ -0,0 +1,55 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, ClipboardList } from 'lucide-react'
export default function JuryError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Jury section error:', error)
}, [error])
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
An error occurred while loading this page. Please try again or
return to your assignments.
</p>
<div className="flex justify-center gap-2">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/jury">
<ClipboardList className="mr-2 h-4 w-4" />
My Assignments
</Link>
</Button>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,362 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
CheckCircle2,
Clock,
FileText,
ExternalLink,
AlertCircle,
} from 'lucide-react'
import { formatDate, truncate } from '@/lib/utils'
async function AssignmentsContent({
roundId,
}: {
roundId?: string
}) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
// Get assignments, optionally filtered by round
const assignments = await prisma.assignment.findMany({
where: {
userId,
...(roundId ? { roundId } : {}),
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
status: true,
files: {
select: {
id: true,
fileType: true,
},
},
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
updatedAt: true,
},
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' },
],
})
if (assignments.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments found</p>
<p className="text-sm text-muted-foreground">
{roundId
? 'No projects assigned to you for this round'
: "You don't have any project assignments yet"}
</p>
</CardContent>
</Card>
)
}
const now = new Date()
return (
<div className="space-y-6">
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Deadline</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<TableRow key={assignment.id}>
<TableCell>
<div>
<p className="font-medium">
{truncate(assignment.project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{assignment.project.teamName}
</p>
</div>
</TableCell>
<TableCell>
<div>
<p>{assignment.round.name}</p>
<p className="text-sm text-muted-foreground">
{assignment.round.program.name}
</p>
</div>
</TableCell>
<TableCell>
{assignment.round.votingEndAt ? (
<span
className={
new Date(assignment.round.votingEndAt) < now
? 'text-muted-foreground'
: ''
}
>
{formatDate(assignment.round.votingEndAt)}
</span>
) : (
<span className="text-muted-foreground">No deadline</span>
)}
</TableCell>
<TableCell>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
) : isDraft ? (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</TableCell>
<TableCell className="text-right">
{isCompleted ? (
<Button variant="outline" size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${assignment.project.id}`}>
View
</Link>
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{assignments.map((assignment) => {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ACTIVE' &&
assignment.round.votingStartAt &&
assignment.round.votingEndAt &&
new Date(assignment.round.votingStartAt) <= now &&
new Date(assignment.round.votingEndAt) >= now
return (
<Card key={assignment.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-base">
{assignment.project.title}
</CardTitle>
<CardDescription>
{assignment.project.teamName}
</CardDescription>
</div>
{isCompleted ? (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Done
</Badge>
) : isDraft ? (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
Draft
</Badge>
) : (
<Badge variant="secondary">Pending</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{assignment.round.name}</span>
</div>
{assignment.round.votingEndAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{formatDate(assignment.round.votingEndAt)}</span>
</div>
)}
<div className="pt-2">
{isCompleted ? (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link
href={`/jury/projects/${assignment.project.id}/evaluation`}
>
View Evaluation
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" className="w-full" asChild>
<Link
href={`/jury/projects/${assignment.project.id}/evaluate`}
>
{isDraft ? 'Continue Evaluation' : 'Start Evaluation'}
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
asChild
>
<Link href={`/jury/projects/${assignment.project.id}`}>
View Project
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function AssignmentsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default async function JuryAssignmentsPage({
searchParams,
}: {
searchParams: Promise<{ round?: string }>
}) {
const params = await searchParams
const roundId = params.round
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Assignments</h1>
<p className="text-muted-foreground">
Projects assigned to you for evaluation
</p>
</div>
{/* Content */}
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentsContent roundId={roundId} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function JuryLearningPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
const utils = trpc.useUtils()
const handleDownload = async (resourceId: string) => {
setDownloadingId(resourceId)
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloadingId(null)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Educational resources for jury members
</p>
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const resources = data?.resources || []
const userCohortLevel = data?.userCohortLevel || 'ALL'
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Educational resources for jury members
</p>
{userCohortLevel !== 'ALL' && (
<Badge className={cohortColors[userCohortLevel]} variant="outline">
Your access level: {userCohortLevel}
</Badge>
)}
</div>
{resources.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources available</h3>
<p className="text-muted-foreground">
Check back later for learning materials
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
const isDownloading = downloadingId === resource.id
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
) : resource.objectKey ? (
<Button
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
) : null}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
'use client'
import { use, useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap } from 'lucide-react'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data with polling
const { data, isLoading, refetch } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
// Vote mutation
const vote = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
toast.success('Vote recorded')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Set selected score from existing vote
useEffect(() => {
if (data?.userVote) {
setSelectedScore(data.userVote.score)
} else {
setSelectedScore(null)
}
}, [data?.userVote, data?.currentProject?.id])
const handleVote = (score: number) => {
if (!data?.currentProject) return
setSelectedScore(score)
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
})
}
if (isLoading) {
return <JuryVotingSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
const isVoting = data.session.status === 'IN_PROGRESS'
const hasVoted = !!data.userVote
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-6 w-6 text-primary" />
<CardTitle>Live Voting</CardTitle>
</div>
<CardDescription>
{data.round.program.name} - {data.round.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Score buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
{/* Vote status */}
{hasVoted && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'The admin will start voting for the next project.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Mobile-friendly footer */}
<p className="text-white/60 text-sm mt-4">
MOPC Live Voting
</p>
</div>
)
}
function JuryVotingSkeleton() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Skeleton className="h-6 w-32 mx-auto" />
<Skeleton className="h-4 w-48 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function JuryLiveVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <JuryVotingContent sessionId={sessionId} />
}

View File

@@ -0,0 +1,320 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const metadata: Metadata = { title: 'Jury Dashboard' }
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
ClipboardList,
CheckCircle2,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
async function JuryDashboardContent() {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
return null
}
// Get all assignments for this jury member
const assignments = await prisma.assignment.findMany({
where: {
userId,
},
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
status: true,
},
},
round: {
select: {
id: true,
name: true,
status: true,
votingStartAt: true,
votingEndAt: true,
program: {
select: {
name: true,
},
},
},
},
evaluation: {
select: {
id: true,
status: true,
submittedAt: true,
},
},
},
orderBy: [
{ round: { votingEndAt: 'asc' } },
{ createdAt: 'asc' },
],
})
// Calculate stats
const totalAssignments = assignments.length
const completedAssignments = assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const inProgressAssignments = assignments.filter(
(a) => a.evaluation?.status === 'DRAFT'
).length
const pendingAssignments =
totalAssignments - completedAssignments - inProgressAssignments
const completionRate =
totalAssignments > 0 ? (completedAssignments / totalAssignments) * 100 : 0
// Group assignments by round
const assignmentsByRound = assignments.reduce(
(acc, assignment) => {
const roundId = assignment.round.id
if (!acc[roundId]) {
acc[roundId] = {
round: assignment.round,
assignments: [],
}
}
acc[roundId].assignments.push(assignment)
return acc
},
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
)
// Get active rounds (voting window is open)
const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter(
({ round }) =>
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
)
return (
<>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Assignments
</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<Clock className="h-4 w-4 text-amber-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{inProgressAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingAssignments}</div>
</CardContent>
</Card>
</div>
{/* Progress */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Progress</CardTitle>
</CardHeader>
<CardContent>
<Progress value={completionRate} className="h-3" />
<p className="mt-2 text-sm text-muted-foreground">
{completedAssignments} of {totalAssignments} evaluations completed (
{completionRate.toFixed(0)}%)
</p>
</CardContent>
</Card>
{/* Active Rounds */}
{activeRounds.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Active Voting Rounds</CardTitle>
<CardDescription>
These rounds are currently open for evaluation
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{activeRounds.map(({ round, assignments: roundAssignments }) => {
const roundCompleted = roundAssignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const roundTotal = roundAssignments.length
const roundProgress =
roundTotal > 0 ? (roundCompleted / roundTotal) * 100 : 0
return (
<div
key={round.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">{round.name}</h3>
<p className="text-sm text-muted-foreground">
{round.program.name}
</p>
</div>
<Badge variant="default">Active</Badge>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span>
{roundCompleted}/{roundTotal}
</span>
</div>
<Progress value={roundProgress} className="h-2" />
</div>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Deadline: {formatDateOnly(round.votingEndAt)}
</p>
)}
<Button asChild size="sm" className="w-full sm:w-auto">
<Link href={`/jury/assignments?round=${round.id}`}>
View Assignments
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)
})}
</CardContent>
</Card>
)}
{/* No active rounds message */}
{activeRounds.length === 0 && totalAssignments > 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Clock className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No active voting rounds</p>
<p className="text-sm text-muted-foreground">
Check back later when a voting window opens
</p>
</CardContent>
</Card>
)}
{/* No assignments message */}
{totalAssignments === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No assignments yet</p>
<p className="text-sm text-muted-foreground">
You&apos;ll see your project assignments here once they&apos;re
assigned
</p>
</CardContent>
</Card>
)}
</>
)
}
function DashboardSkeleton() {
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
<Skeleton className="mt-2 h-4 w-48" />
</CardContent>
</Card>
</>
)
}
export default async function JuryDashboardPage() {
const session = await auth()
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {session?.user?.name || 'Juror'}
</p>
</div>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,318 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationForm } from '@/components/forms/evaluation-form'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
// Define the criterion type for the evaluation form
interface Criterion {
id: string
label: string
description?: string
scale: number
weight?: number
required?: boolean
}
async function EvaluateContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
},
})
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const round = project.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed = round.votingEndAt && isPast(new Date(round.votingEndAt))
// Check for grace period
const gracePeriod = await prisma.gracePeriod.findFirst({
where: {
roundId: round.id,
userId,
OR: [{ projectId: null }, { projectId }],
extendedUntil: { gte: now },
},
})
const hasGracePeriod = !!gracePeriod
const effectiveVotingOpen = isVotingOpen || hasGracePeriod
// Check if already submitted
const evaluation = assignment.evaluation
const isSubmitted =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
if (isSubmitted) {
redirect(`/jury/projects/${projectId}/evaluation`)
}
// Get evaluation form criteria
const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">Evaluation Form Not Available</p>
<p className="text-sm text-muted-foreground">
The evaluation criteria for this round have not been configured yet.
Please check back later.
</p>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from JSON
const criteria: Criterion[] = (evaluationForm.criteriaJson as unknown as Criterion[]) || []
// Handle voting not open
if (!effectiveVotingOpen) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-amber-500/50" />
<p className="mt-2 font-medium">
{isVotingUpcoming ? 'Voting Not Yet Open' : 'Voting Period Closed'}
</p>
<p className="text-sm text-muted-foreground">
{isVotingUpcoming
? 'The voting window for this round has not started yet.'
: 'The voting window for this round has ended.'}
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Back button and project summary */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-xl font-semibold">Evaluate: {project.title}</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
</div>
{/* Quick file access */}
{project.files.length > 0 && (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
</span>
<Button variant="outline" size="sm" asChild>
<Link href={`/jury/projects/${projectId}`}>View Files</Link>
</Button>
</div>
)}
</div>
{/* Grace period notice */}
{hasGracePeriod && gracePeriod && (
<Card className="border-amber-500 bg-amber-500/5">
<CardContent className="py-3">
<div className="flex items-center gap-2 text-amber-600">
<Clock className="h-4 w-4" />
<span className="text-sm font-medium">
You have a grace period extension until{' '}
{new Date(gracePeriod.extendedUntil).toLocaleString()}
</span>
</div>
</CardContent>
</Card>
)}
{/* Evaluation Form */}
<EvaluationForm
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
criteria={criteria}
initialData={
evaluation
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
feedbackText: evaluation.feedbackText,
status: evaluation.status,
}
: undefined
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
/>
</div>
)
}
function EvaluateSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-80" />
<Skeleton className="h-4 w-32" />
</div>
<Card>
<CardContent className="p-6 space-y-6">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluatePage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluateSkeleton />}>
<EvaluateContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,394 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
CheckCircle2,
ThumbsUp,
ThumbsDown,
Calendar,
Users,
Star,
AlertCircle,
} from 'lucide-react'
import { format } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
interface Criterion {
id: string
label: string
description?: string
scale: number
weight?: number
required?: boolean
}
async function EvaluationContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: {
include: {
form: true,
},
},
},
})
if (!assignment) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
</CardContent>
</Card>
</div>
)
}
const evaluation = assignment.evaluation
if (!evaluation || evaluation.status === 'NOT_STARTED') {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/jury/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Star className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Evaluation Found</p>
<p className="text-sm text-muted-foreground">
You haven&apos;t submitted an evaluation for this project yet.
</p>
<Button asChild className="mt-4">
<Link href={`/jury/projects/${projectId}/evaluate`}>
Start Evaluation
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Parse criteria from the evaluation form
const criteria: Criterion[] =
(evaluation.form.criteriaJson as unknown as Criterion[]) || []
const criterionScores =
(evaluation.criterionScoresJson as unknown as Record<string, number>) || {}
const round = project.round
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
My Evaluation: {project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<Badge
variant="default"
className="w-fit bg-green-600 hover:bg-green-700"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{evaluation.status === 'LOCKED' ? 'Locked' : 'Submitted'}
</Badge>
</div>
{evaluation.submittedAt && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
Submitted on {format(new Date(evaluation.submittedAt), 'PPP')} at{' '}
{format(new Date(evaluation.submittedAt), 'p')}
</div>
)}
</div>
<Separator />
{/* Criteria scores */}
{criteria.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Criteria Scores</CardTitle>
<CardDescription>
Your ratings for each evaluation criterion
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{criteria.map((criterion) => {
const score = criterionScores[criterion.id]
return (
<div key={criterion.id} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{criterion.label}</p>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{score}</span>
<span className="text-muted-foreground">
/ {criterion.scale}
</span>
</div>
</div>
{/* Visual score bar */}
<div className="flex gap-1">
{Array.from(
{ length: criterion.scale },
(_, i) => i + 1
).map((num) => (
<div
key={num}
className={`h-2 flex-1 rounded-full ${
num <= score
? 'bg-primary'
: 'bg-muted'
}`}
/>
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Global score */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Overall Score</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<span className="text-3xl font-bold text-primary">
{evaluation.globalScore}
</span>
</div>
<div>
<p className="text-lg font-medium">out of 10</p>
<p className="text-sm text-muted-foreground">
{evaluation.globalScore && evaluation.globalScore >= 8
? 'Excellent'
: evaluation.globalScore && evaluation.globalScore >= 6
? 'Good'
: evaluation.globalScore && evaluation.globalScore >= 4
? 'Average'
: 'Below Average'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recommendation */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recommendation</CardTitle>
</CardHeader>
<CardContent>
<div
className={`flex items-center gap-3 rounded-lg p-4 ${
evaluation.binaryDecision
? 'bg-green-500/10 text-green-700 dark:text-green-400'
: 'bg-red-500/10 text-red-700 dark:text-red-400'
}`}
>
{evaluation.binaryDecision ? (
<>
<ThumbsUp className="h-8 w-8" />
<div>
<p className="font-semibold">Recommended to Advance</p>
<p className="text-sm opacity-80">
You voted YES for this project to advance to the next round
</p>
</div>
</>
) : (
<>
<ThumbsDown className="h-8 w-8" />
<div>
<p className="font-semibold">Not Recommended</p>
<p className="text-sm opacity-80">
You voted NO for this project to advance
</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Feedback */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Written Feedback</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted p-4">
<p className="whitespace-pre-wrap">{evaluation.feedbackText}</p>
</div>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" asChild>
<Link href={`/jury/projects/${projectId}`}>View Project Details</Link>
</Button>
<Button asChild>
<Link href="/jury/assignments">Back to All Assignments</Link>
</Button>
</div>
</div>
)
}
function EvaluationSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-px w-full" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-16" />
</div>
<Skeleton className="h-2 w-full" />
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-32 rounded-full" />
</CardContent>
</Card>
</div>
)
}
export default async function EvaluationViewPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<EvaluationSkeleton />}>
<EvaluationContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,489 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { FileViewer, FileViewerSkeleton } from '@/components/shared/file-viewer'
import {
ArrowLeft,
ArrowRight,
Users,
Calendar,
Clock,
CheckCircle2,
Edit3,
Tag,
FileText,
AlertCircle,
} from 'lucide-react'
import { formatDistanceToNow, format, isPast, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
async function ProjectContent({ projectId }: { projectId: string }) {
const session = await auth()
const userId = session?.user?.id
if (!userId) {
redirect('/login')
}
// Get project with assignment info for this user
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: true,
round: {
include: {
program: {
select: { name: true },
},
evaluationForms: {
where: { isActive: true },
take: 1,
},
},
},
},
})
if (!project) {
notFound()
}
// Check if user is assigned to this project
const assignment = await prisma.assignment.findFirst({
where: {
projectId,
userId,
},
include: {
evaluation: true,
},
})
if (!assignment) {
// User is not assigned to this project
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Access Denied</p>
<p className="text-sm text-muted-foreground">
You are not assigned to evaluate this project
</p>
<Button asChild className="mt-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
</CardContent>
</Card>
)
}
const evaluation = assignment.evaluation
const round = project.round
const now = new Date()
// Check voting window
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const isVotingUpcoming =
round.votingStartAt && isFuture(new Date(round.votingStartAt))
const isVotingClosed =
round.votingEndAt && isPast(new Date(round.votingEndAt))
// Determine evaluation status
const getEvaluationStatus = () => {
if (!evaluation)
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
switch (evaluation.status) {
case 'DRAFT':
return { label: 'In Progress', variant: 'secondary' as const, icon: Edit3 }
case 'SUBMITTED':
return { label: 'Submitted', variant: 'default' as const, icon: CheckCircle2 }
case 'LOCKED':
return { label: 'Locked', variant: 'default' as const, icon: CheckCircle2 }
default:
return { label: 'Not Started', variant: 'outline' as const, icon: Clock }
}
}
const status = getEvaluationStatus()
const StatusIcon = status.icon
const canEvaluate =
isVotingOpen &&
evaluation?.status !== 'SUBMITTED' &&
evaluation?.status !== 'LOCKED'
const canViewEvaluation =
evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED'
return (
<div className="space-y-6">
{/* Back button */}
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Assignments
</Link>
</Button>
{/* Project Header */}
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{round.program.name}</span>
<span>/</span>
<span>{round.name}</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{project.title}
</h1>
{project.teamName && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{project.teamName}</span>
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Badge variant={status.variant} className="w-fit">
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
{round.votingEndAt && (
<DeadlineDisplay
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
)}
</div>
</div>
{/* Tags */}
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
<Tag className="mr-1 h-3 w-3" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-3">
{canEvaluate && (
<Button asChild>
<Link href={`/jury/projects/${project.id}/evaluate`}>
{evaluation?.status === 'DRAFT' ? 'Continue Evaluation' : 'Start Evaluation'}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
)}
{canViewEvaluation && (
<Button variant="secondary" asChild>
<Link href={`/jury/projects/${project.id}/evaluation`}>
View My Evaluation
</Link>
</Button>
)}
{!isVotingOpen && !canViewEvaluation && (
<Button disabled>
{isVotingUpcoming
? 'Voting Not Yet Open'
: isVotingClosed
? 'Voting Closed'
: 'Evaluation Unavailable'}
</Button>
)}
</div>
<Separator />
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Description - takes 2 columns on large screens */}
<div className="lg:col-span-2 space-y-6">
{/* Description */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Description</CardTitle>
</CardHeader>
<CardContent>
{project.description ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
) : (
<p className="text-muted-foreground italic">
No description provided
</p>
)}
</CardContent>
</Card>
{/* Files */}
<FileViewer files={project.files} />
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Round Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Round Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Round</span>
<span className="text-sm font-medium">{round.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Program</span>
<span className="text-sm font-medium">{round.program.name}</span>
</div>
<Separator />
{round.votingStartAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Opens</span>
<span className="text-sm">
{format(new Date(round.votingStartAt), 'PPp')}
</span>
</div>
)}
{round.votingEndAt && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Voting Closes</span>
<span className="text-sm">
{format(new Date(round.votingEndAt), 'PPp')}
</span>
</div>
)}
<Separator />
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<RoundStatusBadge
status={round.status}
votingStartAt={round.votingStartAt}
votingEndAt={round.votingEndAt}
/>
</div>
</CardContent>
</Card>
{/* Evaluation Progress */}
{evaluation && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Your Evaluation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span>
<Badge variant={status.variant}>
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</div>
{evaluation.status === 'DRAFT' && (
<p className="text-sm text-muted-foreground">
Last saved{' '}
{formatDistanceToNow(new Date(evaluation.updatedAt), {
addSuffix: true,
})}
</p>
)}
{evaluation.status === 'SUBMITTED' && evaluation.submittedAt && (
<p className="text-sm text-muted-foreground">
Submitted{' '}
{formatDistanceToNow(new Date(evaluation.submittedAt), {
addSuffix: true,
})}
</p>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function DeadlineDisplay({
votingStartAt,
votingEndAt,
}: {
votingStartAt: Date | null
votingEndAt: Date
}) {
const now = new Date()
const endDate = new Date(votingEndAt)
const startDate = votingStartAt ? new Date(votingStartAt) : null
if (startDate && isFuture(startDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
Opens {format(startDate, 'PPp')}
</div>
)
}
if (isPast(endDate)) {
return (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
Closed {formatDistanceToNow(endDate, { addSuffix: true })}
</div>
)
}
const daysRemaining = Math.ceil(
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
)
const isUrgent = daysRemaining <= 3
return (
<div
className={`flex items-center gap-1 text-sm ${
isUrgent ? 'text-amber-600 font-medium' : 'text-muted-foreground'
}`}
>
<Clock className="h-3 w-3" />
{daysRemaining <= 0
? `Due ${formatDistanceToNow(endDate, { addSuffix: true })}`
: `${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining`}
</div>
)
}
function RoundStatusBadge({
status,
votingStartAt,
votingEndAt,
}: {
status: string
votingStartAt: Date | null
votingEndAt: Date | null
}) {
const now = new Date()
const isVotingOpen =
status === 'ACTIVE' &&
votingStartAt &&
votingEndAt &&
new Date(votingStartAt) <= now &&
new Date(votingEndAt) >= now
if (isVotingOpen) {
return <Badge variant="default">Voting Open</Badge>
}
if (status === 'ACTIVE' && votingStartAt && isFuture(new Date(votingStartAt))) {
return <Badge variant="secondary">Upcoming</Badge>
}
if (status === 'ACTIVE' && votingEndAt && isPast(new Date(votingEndAt))) {
return <Badge variant="outline">Voting Closed</Badge>
}
return <Badge variant="secondary">{status}</Badge>
}
function ProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2 sm:items-end">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-px w-full" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
<FileViewerSkeleton />
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
)
}
export default async function ProjectDetailPage({ params }: PageProps) {
const { id } = await params
return (
<Suspense fallback={<ProjectSkeleton />}>
<ProjectContent projectId={id} />
</Suspense>
)
}

36
src/app/(jury)/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect'
import { JuryNav } from '@/components/layouts/jury-nav'
export const dynamic = 'force-dynamic'
export default async function JuryLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('JURY_MEMBER')
// Check if user has completed onboarding
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
redirect('/onboarding')
}
return (
<div className="min-h-screen bg-background">
<JuryNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Users } from 'lucide-react'
export default function MentorError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Mentor section error:', error)
}, [error])
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
An error occurred while loading this page. Please try again or
return to your mentee dashboard.
</p>
<div className="flex justify-center gap-2">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href={'/mentor' as Route}>
<Users className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireRole } from '@/lib/auth-redirect'
import { MentorNav } from '@/components/layouts/mentor-nav'
export const dynamic = 'force-dynamic'
export default async function MentorLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
// Check if user has completed onboarding (for mentors)
if (session.user.role === 'MENTOR') {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
redirect('/onboarding')
}
}
return (
<div className="min-h-screen bg-background">
<MentorNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6 lg:py-8">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import type { Metadata } from 'next'
export const metadata: Metadata = { title: 'Mentor Dashboard' }
export default function MentorPageLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@@ -0,0 +1,252 @@
'use client'
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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Users,
Briefcase,
ArrowRight,
Mail,
MapPin,
GraduationCap,
Waves,
Crown,
} from 'lucide-react'
import { getInitials, formatDateOnly } from '@/lib/utils'
// Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'default',
FINALIST: 'default',
REJECTED: 'destructive',
}
function DashboardSkeleton() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64 mt-2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-6 w-32" />
<div className="grid gap-4">
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
</div>
)
}
export default function MentorDashboard() {
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
if (isLoading) {
return <DashboardSkeleton />
}
const projects = assignments || []
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Mentor Dashboard
</h1>
<p className="text-muted-foreground">
View and manage your assigned mentee projects
</p>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Assigned Projects
</CardTitle>
<Briefcase className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projects.length}</div>
<p className="text-xs text-muted-foreground">
Projects you are mentoring
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Team Members
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{projects.reduce(
(acc, a) => acc + (a.project.teamMembers?.length || 0),
0
)}
</div>
<p className="text-xs text-muted-foreground">
Across all assigned projects
</p>
</CardContent>
</Card>
</div>
{/* Projects List */}
<div>
<h2 className="text-lg font-semibold mb-4">Your Mentees</h2>
{projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mt-4 font-medium">No assigned projects yet</p>
<p className="text-sm text-muted-foreground mt-1">
You will see your mentee projects here once they are assigned to
you.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{projects.map((assignment) => {
const project = assignment.project
const teamLead = project.teamMembers?.find(
(m) => m.role === 'LEAD'
)
return (
<Card key={assignment.id}>
<CardHeader>
<div className="flex flex-col gap-2 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>
{project.round.program.name} {project.round.program.year}
</span>
<span></span>
<span>{project.round.name}</span>
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
<Badge
variant={statusColors[project.status] || 'secondary'}
>
{project.status.replace('_', ' ')}
</Badge>
</CardTitle>
{project.teamName && (
<CardDescription>{project.teamName}</CardDescription>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/mentor/projects/${project.id}` as Route}>
View Details
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category 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.country && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{project.country}
</Badge>
)}
</div>
{/* Description preview */}
{project.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
{/* Team Lead Info */}
{teamLead && (
<div className="flex items-center gap-3 pt-2 border-t">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
<Crown className="h-4 w-4 text-yellow-500" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{teamLead.user.name || 'Unnamed'}{' '}
<span className="text-muted-foreground font-normal">
(Team Lead)
</span>
</p>
<a
href={`mailto:${teamLead.user.email}`}
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
>
<Mail className="h-3 w-3" />
{teamLead.user.email}
</a>
</div>
<div className="text-xs text-muted-foreground">
{project.teamMembers?.length || 0} team member
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
)}
{/* Assignment date */}
<p className="text-xs text-muted-foreground">
Assigned {formatDateOnly(assignment.assignedAt)}
</p>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,415 @@
'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 { FileViewer } from '@/components/shared/file-viewer'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import {
ArrowLeft,
AlertCircle,
Users,
MapPin,
Waves,
GraduationCap,
Crown,
Mail,
Phone,
Calendar,
FileText,
ExternalLink,
} from 'lucide-react'
import { 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',
}
function ProjectDetailContent({ projectId }: { projectId: string }) {
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
projectId,
})
if (isLoading) {
return <ProjectDetailSkeleton />
}
if (error || !project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href={'/mentor' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</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">
{error?.message || 'Project Not Found'}
</p>
<p className="text-sm text-muted-foreground mt-1">
You may not have access to view this project.
</p>
<Button asChild className="mt-4">
<Link href={'/mentor' as Route}>Back to Dashboard</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={'/mentor' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</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">
<span>
{project.round.program.name} {project.round.program.year}
</span>
<span></span>
<span>{project.round.name}</span>
</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>
</div>
{project.assignedAt && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Assigned to you on {formatDateOnly(project.assignedAt)}</span>
</div>
)}
<Separator />
{/* 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>
)}
</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].filter(Boolean).join(', ')}
</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">
<ExternalLink className="mr-2 h-4 w-4" />
Phase 1 Submission
</a>
</Button>
)}
{project.phase2SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
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>
)}
</CardContent>
</Card>
{/* Team Members Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members ({project.teamMembers?.length || 0})
</CardTitle>
<CardDescription>
Contact information for the project team
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Team Lead */}
{teamLead && (
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
<Crown className="h-6 w-6 text-yellow-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium">{teamLead.user.name || 'Unnamed'}</p>
<Badge variant="secondary" className="text-xs">Team Lead</Badge>
</div>
{teamLead.title && (
<p className="text-sm text-muted-foreground mb-2">{teamLead.title}</p>
)}
<div className="flex flex-wrap gap-4 text-sm">
<a
href={`mailto:${teamLead.user.email}`}
className="flex items-center gap-1 text-primary hover:underline"
>
<Mail className="h-4 w-4" />
{teamLead.user.email}
</a>
{teamLead.user.phoneNumber && (
<a
href={`tel:${teamLead.user.phoneNumber}`}
className="flex items-center gap-1 text-primary hover:underline"
>
<Phone className="h-4 w-4" />
{teamLead.user.phoneNumber}
</a>
)}
</div>
</div>
</div>
</div>
)}
{/* Other Team Members */}
{otherMembers.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{otherMembers.map((member) => (
<div key={member.id} className="flex items-start gap-3 p-3 rounded-lg border">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<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 === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
<a
href={`mailto:${member.user.email}`}
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 mt-1"
>
<Mail className="h-3 w-3" />
{member.user.email}
</a>
</div>
</div>
))}
</div>
)}
{!project.teamMembers?.length && (
<p className="text-sm text-muted-foreground text-center py-4">
No team members listed for this project.
</p>
)}
</CardContent>
</Card>
{/* Files Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
Project Files
</CardTitle>
<CardDescription>
Documents and materials submitted by the team
</CardDescription>
</CardHeader>
<CardContent>
{project.files && project.files.length > 0 ? (
<FileViewer
files={project.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 text-center py-6">
No files have been uploaded for this project yet.
</p>
)}
</CardContent>
</Card>
</div>
)
}
function ProjectDetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-start gap-4">
<Skeleton className="h-16 w-16 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<Skeleton className="h-px w-full" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorProjectDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<ProjectDetailSkeleton />}>
<ProjectDetailContent projectId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,192 @@
'use client'
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 {
Users,
ArrowRight,
Mail,
MapPin,
GraduationCap,
Waves,
Crown,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
// Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'default',
FINALIST: 'default',
REJECTED: 'destructive',
}
function ProjectsSkeleton() {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-48 mt-2" />
</div>
<div className="grid gap-4">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
</div>
)
}
export default function MentorProjectsPage() {
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
if (isLoading) {
return <ProjectsSkeleton />
}
const projects = assignments || []
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Mentees</h1>
<p className="text-muted-foreground">
All projects assigned to you for mentorship
</p>
</div>
{/* Projects List */}
{projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="mt-4 font-medium">No assigned projects yet</p>
<p className="text-sm text-muted-foreground mt-1">
You will see your mentee projects here once they are assigned to you.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{projects.map((assignment) => {
const project = assignment.project
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
return (
<Card key={assignment.id}>
<CardHeader>
<div className="flex flex-col gap-2 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>
{project.round.program.name} {project.round.program.year}
</span>
<span></span>
<span>{project.round.name}</span>
</div>
<CardTitle className="flex items-center gap-2">
{project.title}
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</CardTitle>
{project.teamName && (
<CardDescription>{project.teamName}</CardDescription>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/mentor/projects/${project.id}` as Route}>
View Details
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category 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.country && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{project.country}
</Badge>
)}
</div>
{/* Description preview */}
{project.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
{/* Team Lead Info */}
{teamLead && (
<div className="flex items-center gap-3 pt-2 border-t">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-100">
<Crown className="h-4 w-4 text-yellow-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{teamLead.user.name || 'Unnamed'}{' '}
<span className="text-muted-foreground font-normal">
(Team Lead)
</span>
</p>
<a
href={`mailto:${teamLead.user.email}`}
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
>
<Mail className="h-3 w-3" />
{teamLead.user.email}
</a>
</div>
<div className="text-xs text-muted-foreground">
{project.teamMembers?.length || 0} team member
{(project.teamMembers?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
)}
{/* Assignment date */}
<p className="text-xs text-muted-foreground">
Assigned {formatDateOnly(assignment.assignedAt)}
</p>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function MentorResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
const utils = trpc.useUtils()
const handleDownload = async (resourceId: string) => {
setDownloadingId(resourceId)
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloadingId(null)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Resources</h1>
<p className="text-muted-foreground">
Guides and materials to help you mentor effectively
</p>
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const resources = data?.resources || []
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Resources</h1>
<p className="text-muted-foreground">
Guides and materials to help you mentor effectively
</p>
</div>
{resources.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources available yet</h3>
<p className="text-muted-foreground text-center max-w-md">
Mentor guides and training materials will appear here once they are published by the program administrators.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
const isDownloading = downloadingId === resource.id
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
) : resource.objectKey ? (
<Button
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
) : null}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Eye } from 'lucide-react'
export default function ObserverError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Observer section error:', error)
}, [error])
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
An error occurred while loading this page. Please try again or
return to the observer dashboard.
</p>
<div className="flex justify-center gap-2">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/observer">
<Eye className="mr-2 h-4 w-4" />
Dashboard
</Link>
</Button>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { requireRole } from '@/lib/auth-redirect'
import { ObserverNav } from '@/components/layouts/observer-nav'
export default async function ObserverLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('OBSERVER')
return (
<div className="min-h-screen bg-background">
<ObserverNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6">{children}</main>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const metadata: Metadata = { title: 'Observer Dashboard' }
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
FolderKanban,
ClipboardList,
Users,
CheckCircle2,
Eye,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
async function ObserverDashboardContent() {
const [
programCount,
activeRoundCount,
projectCount,
jurorCount,
evaluationStats,
recentRounds,
] = await Promise.all([
prisma.program.count(),
prisma.round.count({ where: { status: 'ACTIVE' } }),
prisma.project.count(),
prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }),
prisma.evaluation.groupBy({
by: ['status'],
_count: true,
}),
prisma.round.findMany({
orderBy: { createdAt: 'desc' },
take: 5,
include: {
program: { select: { name: true } },
_count: {
select: {
projects: true,
assignments: true,
},
},
},
}),
])
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
const draftCount =
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
const totalEvaluations = submittedCount + draftCount
const completionRate =
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
return (
<>
{/* Observer Notice */}
<Card className="border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30">
<CardContent className="flex items-center gap-3 py-4">
<Eye className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-100">
Observer Mode
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
You have read-only access to view platform statistics and reports.
</p>
</div>
</CardContent>
</Card>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{programCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{jurorCount}</div>
<p className="text-xs text-muted-foreground">Active members</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{submittedCount}</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</div>
{/* Recent Rounds */}
<Card>
<CardHeader>
<CardTitle>Recent Rounds</CardTitle>
<CardDescription>Overview of the latest voting rounds</CardDescription>
</CardHeader>
<CardContent>
{recentRounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet
</p>
</div>
) : (
<div className="space-y-4">
{recentRounds.map((round) => (
<div
key={round.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round.program.name}
</p>
</div>
<div className="text-right text-sm">
<p>{round._count.projects} projects</p>
<p className="text-muted-foreground">
{round._count.assignments} assignments
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
)
}
function DashboardSkeleton() {
return (
<>
<Skeleton className="h-20 w-full" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
</>
)
}
export default async function ObserverDashboardPage() {
const session = await auth()
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome, {session?.user?.name || 'Observer'}
</p>
</div>
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<ObserverDashboardContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,310 @@
import { Suspense } from 'react'
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 { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileSpreadsheet, BarChart3, Users, ClipboardList } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
async function ReportsContent() {
// Get rounds with evaluation stats
const rounds = await prisma.round.findMany({
include: {
program: {
select: {
name: true,
},
},
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
id: true,
evaluation: {
select: { id: true, status: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
})
// Calculate completion stats for each round
const roundStats = rounds.map((round) => {
const totalAssignments = round._count.assignments
const completedEvaluations = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const completionRate =
totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return {
...round,
totalAssignments,
completedEvaluations,
completionRate,
}
})
// Calculate totals
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments,
0
)
const totalEvaluations = roundStats.reduce(
(acc, r) => acc + r.completedEvaluations,
0
)
if (rounds.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Reports will appear here once rounds are created
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{rounds.filter((r) => r.status === 'ACTIVE').length} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAssignments}</div>
<p className="text-xs text-muted-foreground">Total assignments</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalEvaluations}</div>
<p className="text-xs text-muted-foreground">Completed</p>
</CardContent>
</Card>
</div>
{/* Rounds Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardDescription>Progress overview for each round</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roundStats.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
<p className="font-medium">{round.name}</p>
{round.votingEndAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>
<div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm">
<span>
{round.completedEvaluations}/{round.totalAssignments}
</span>
<span className="text-muted-foreground">
{round.completionRate}%
</span>
</div>
<Progress value={round.completionRate} className="h-2" />
</div>
</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Rounds Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Round Reports</h2>
{roundStats.map((round) => (
<Card key={round.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{round.program.name}</p>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
<div className="flex items-center justify-between text-sm">
<span>{round._count.projects} projects</span>
<span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span className="text-muted-foreground">{round.completionRate}%</span>
</div>
<Progress value={round.completionRate} className="h-2" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
function ReportsSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ObserverReportsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground">
View evaluation progress and statistics
</p>
</div>
{/* Content */}
<Suspense fallback={<ReportsSkeleton />}>
<ReportsContent />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,423 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
roundId: config.round.id,
data,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error.message}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@@ -0,0 +1,430 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } 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 { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
type FormField = {
id: string
fieldType: string
name: string
label: string
description?: string | null
placeholder?: string | null
required: boolean
minLength?: number | null
maxLength?: number | null
minValue?: number | null
maxValue?: number | null
optionsJson: Array<{ value: string; label: string }> | null
conditionJson: { fieldId: string; operator: string; value?: string } | null
width: string
}
export default function PublicFormPage() {
const params = useParams()
const slug = params.slug as string
const [submitted, setSubmitted] = useState(false)
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
{ slug },
{ retry: false }
)
const submitMutation = trpc.applicationForm.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setConfirmationMessage(result.confirmationMessage || null)
},
onError: (error) => {
toast.error(error.message)
},
})
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm()
const watchedValues = watch()
const onSubmit = async (data: Record<string, unknown>) => {
if (!form) return
// Extract email and name if present
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
const email = emailField ? (data[emailField.name] as string) : undefined
// Find a name field (common patterns)
const nameField = form.fields.find(
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
)
const name = nameField ? (data[nameField.name] as string) : undefined
await submitMutation.mutateAsync({
formId: form.id,
data,
email,
name,
})
}
if (isLoading) {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
if (error) {
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-destructive mb-4" />
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2>
<p className="text-muted-foreground text-center">
{error.message}
</p>
</CardContent>
</Card>
</div>
)
}
if (submitted) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">Thank You!</h2>
<p className="text-muted-foreground text-center">
{confirmationMessage || 'Your submission has been received.'}
</p>
</CardContent>
</Card>
</div>
)
}
if (!form) return null
// Check if a field should be visible based on conditions
const isFieldVisible = (field: FormField): boolean => {
if (!field.conditionJson) return true
const condition = field.conditionJson
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
switch (condition.operator) {
case 'equals':
return dependentValue === condition.value
case 'not_equals':
return dependentValue !== condition.value
case 'not_empty':
return !!dependentValue && dependentValue !== ''
case 'contains':
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
default:
return true
}
}
const renderField = (field: FormField) => {
if (!isFieldVisible(field)) return null
const fieldError = errors[field.name]
const errorMessage = fieldError?.message as string | undefined
switch (field.fieldType) {
case 'SECTION':
return (
<div key={field.id} className="col-span-full pt-6 pb-2">
<h3 className="text-lg font-semibold">{field.label}</h3>
{field.description && (
<p className="text-sm text-muted-foreground">{field.description}</p>
)}
</div>
)
case 'INSTRUCTIONS':
return (
<div key={field.id} className="col-span-full">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm">{field.description || field.label}</p>
</div>
</div>
)
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'NUMBER':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type="number"
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
valueAsNumber: true,
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'TEXTAREA':
return (
<div key={field.id} className="col-span-full">
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Textarea
id={field.name}
placeholder={field.placeholder || undefined}
rows={4}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'DATE':
case 'DATETIME':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'SELECT':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Select
onValueChange={(value) => setValue(field.name, value)}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || 'Select an option'} />
</SelectTrigger>
<SelectContent>
{(field.optionsJson || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'RADIO':
return (
<div key={field.id} className="col-span-full">
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<RadioGroup
onValueChange={(value) => setValue(field.name, value)}
className="mt-2"
>
{(field.optionsJson || []).map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal">
{option.label}
</Label>
</div>
))}
</RadioGroup>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'CHECKBOX':
return (
<div key={field.id} className="col-span-full">
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
onCheckedChange={(checked) => setValue(field.name, checked)}
/>
<Label htmlFor={field.name} className="font-normal">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{field.description && (
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
)}
<input
type="hidden"
{...register(field.name, {
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
default:
return null
}
}
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>{form.name}</CardTitle>
{form.description && (
<CardDescription>{form.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{form.fields.map((field) => renderField(field as FormField))}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || submitMutation.isPending}
>
{(isSubmitting || submitMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
export default function PublicError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Public section error:', error)
}, [error])
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
An error occurred while loading this page. Please try again or
return to the home page.
</p>
<div className="flex justify-center gap-2">
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/">
<Home className="mr-2 h-4 w-4" />
Home
</Link>
</Button>
</div>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function PublicLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className={`min-h-screen bg-background ${inter.className}`}>
{/* Simple header */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-sm font-bold text-white">M</span>
</div>
<span className="font-semibold">Monaco Ocean Protection Challenge</span>
</div>
</div>
</header>
{/* Main content */}
<main className="container mx-auto px-4 py-8">
{children}
</main>
{/* Footer */}
<footer className="border-t bg-card mt-auto">
<div className="container mx-auto px-4 py-6 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} Monaco Ocean Protection Challenge. All rights reserved.</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,217 @@
'use client'
import { use } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Trophy, Star, Clock, AlertCircle, Zap } from 'lucide-react'
interface PageProps {
params: Promise<{ sessionId: string }>
}
function PublicScoresContent({ sessionId }: { sessionId: string }) {
// Fetch session data with polling
const { data, isLoading } = trpc.liveVoting.getPublicSession.useQuery(
{ sessionId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
if (isLoading) {
return <PublicScoresSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist.
</AlertDescription>
</Alert>
</div>
)
}
const isCompleted = data.session.status === 'COMPLETED'
const isVoting = data.session.status === 'IN_PROGRESS'
// Sort projects by score for leaderboard
const sortedProjects = [...data.projects].sort(
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
)
// Find max score for progress bars
const maxScore = Math.max(...data.projects.map((p) => p.averageScore || 0), 1)
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="text-center text-white">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-8 w-8" />
<h1 className="text-3xl font-bold">Live Scores</h1>
</div>
<p className="text-white/80">
{data.round.program.name} - {data.round.name}
</p>
<Badge
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
className="mt-2"
>
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : data.session.status}
</Badge>
</div>
{/* Current project highlight */}
{isVoting && data.session.currentProjectId && (
<Card className="border-2 border-green-500 bg-green-500/10 animate-pulse">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-green-400">
<Clock className="h-5 w-5" />
<span className="font-medium">Now Voting</span>
</div>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold text-white">
{data.projects.find((p) => p?.id === data.session.currentProjectId)?.title}
</p>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No scores yet
</p>
) : (
<div className="space-y-4">
{sortedProjects.map((project, index) => {
if (!project) return null
const isCurrent = project.id === data.session.currentProjectId
return (
<div
key={project.id}
className={`rounded-lg p-4 ${
isCurrent
? 'bg-green-500/10 border border-green-500'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-yellow-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-xl font-bold">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} votes
</p>
</div>
</div>
{/* Score bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Footer */}
<p className="text-center text-white/60 text-sm">
Scores update in real-time
</p>
</div>
</div>
)
}
function PublicScoresSkeleton() {
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="text-center">
<Skeleton className="h-10 w-48 mx-auto" />
<Skeleton className="h-4 w-64 mx-auto mt-2" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}
export default function PublicScoresPage({ params }: PageProps) {
const { sessionId } = use(params)
return <PublicScoresContent sessionId={sessionId} />
}

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 />
}

View File

@@ -0,0 +1,38 @@
import { handlers } from '@/lib/auth'
import { checkRateLimit } from '@/lib/rate-limit'
const AUTH_RATE_LIMIT = 10 // requests per window
const AUTH_RATE_WINDOW_MS = 60 * 1000 // 1 minute
function getClientIp(req: Request): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip') ||
'unknown'
)
}
function withRateLimit(handler: (req: Request) => Promise<Response>) {
return async (req: Request) => {
// Only rate limit POST requests (sign-in, magic link sends)
if (req.method === 'POST') {
const ip = getClientIp(req)
const { success, resetAt } = checkRateLimit(`auth:${ip}`, AUTH_RATE_LIMIT, AUTH_RATE_WINDOW_MS)
if (!success) {
return new Response(JSON.stringify({ error: 'Too many authentication attempts' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
},
})
}
}
return handler(req)
}
}
export const GET = handlers.GET
export const POST = withRateLimit(handlers.POST as (req: Request) => Promise<Response>)

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`
return NextResponse.json(
{
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
database: 'connected',
},
},
{ status: 200 }
)
} catch (error) {
console.error('Health check failed:', error)
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
services: {
database: 'disconnected',
},
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import { LocalStorageProvider } from '@/lib/storage/local-provider'
import { getContentType } from '@/lib/storage'
import * as fs from 'fs/promises'
const provider = new LocalStorageProvider()
/**
* Handle GET requests for file downloads
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
const action = searchParams.get('action')
const expires = searchParams.get('expires')
const sig = searchParams.get('sig')
// Validate required parameters
if (!key || !action || !expires || !sig) {
return NextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
)
}
// Verify signature and expiry
const isValid = LocalStorageProvider.verifySignature(
key,
action,
parseInt(expires),
sig
)
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid or expired signature' },
{ status: 403 }
)
}
if (action !== 'download') {
return NextResponse.json(
{ error: 'Invalid action for GET request' },
{ status: 400 }
)
}
try {
const filePath = provider.getAbsoluteFilePath(key)
const data = await fs.readFile(filePath)
const contentType = getContentType(key)
return new NextResponse(data, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=3600',
},
})
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
console.error('Error serving file:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* Handle PUT requests for file uploads
*/
export async function PUT(request: NextRequest) {
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
const action = searchParams.get('action')
const expires = searchParams.get('expires')
const sig = searchParams.get('sig')
// Validate required parameters
if (!key || !action || !expires || !sig) {
return NextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
)
}
// Verify signature and expiry
const isValid = LocalStorageProvider.verifySignature(
key,
action,
parseInt(expires),
sig
)
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid or expired signature' },
{ status: 403 }
)
}
if (action !== 'upload') {
return NextResponse.json(
{ error: 'Invalid action for PUT request' },
{ status: 400 }
)
}
try {
const contentType = request.headers.get('content-type') || 'application/octet-stream'
const data = Buffer.from(await request.arrayBuffer())
await provider.putObject(key, data, contentType)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error('Error uploading file:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,55 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'
import { checkRateLimit } from '@/lib/rate-limit'
const RATE_LIMIT = 100 // requests per window
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
function getClientIp(req: Request): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip') ||
'unknown'
)
}
const handler = (req: Request) => {
const ip = getClientIp(req)
const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
if (!success) {
return new Response(JSON.stringify({ error: 'Too many requests' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((resetAt - Date.now()) / 1000)),
'X-RateLimit-Limit': String(RATE_LIMIT),
'X-RateLimit-Remaining': '0',
},
})
}
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
responseMeta() {
return {
headers: {
'X-RateLimit-Limit': String(RATE_LIMIT),
'X-RateLimit-Remaining': String(remaining),
},
}
},
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path ?? '<no-path>'}:`, error)
}
: undefined,
})
}
export { handler as GET, handler as POST }

38
src/app/error.tsx Normal file
View File

@@ -0,0 +1,38 @@
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
<AlertTriangle className="h-16 w-16 text-destructive/50" />
<h1 className="text-2xl font-semibold">Something went wrong</h1>
<p className="max-w-md text-muted-foreground">
An unexpected error occurred. Please try again or contact support if the
problem persists.
</p>
{error.digest && (
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p>
)}
<div className="flex gap-4">
<Button onClick={() => reset()}>Try Again</Button>
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh Page
</Button>
</div>
</div>
)
}

296
src/app/globals.css Normal file
View File

@@ -0,0 +1,296 @@
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@import "tailwindcss";
/* Source the JS config for extended theme values */
@config "../../tailwind.config.ts";
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */
@theme {
/* Container */
--container-2xl: 1400px;
/* Custom spacing */
--spacing-18: 4.5rem;
--spacing-22: 5.5rem;
/* Font families */
--font-sans: 'Montserrat', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
/* Custom font sizes */
--text-display-lg: 3rem;
--text-display-lg--line-height: 1.1;
--text-display-lg--font-weight: 700;
--text-display: 2.25rem;
--text-display--line-height: 1.2;
--text-display--font-weight: 700;
--text-heading: 1.5rem;
--text-heading--line-height: 1.3;
--text-heading--font-weight: 600;
--text-subheading: 1.125rem;
--text-subheading--line-height: 1.4;
--text-subheading--font-weight: 600;
--text-body: 1rem;
--text-body--line-height: 1.5;
--text-body--font-weight: 400;
--text-small: 0.875rem;
--text-small--line-height: 1.5;
--text-small--font-weight: 400;
--text-tiny: 0.75rem;
--text-tiny--line-height: 1.5;
--text-tiny--font-weight: 400;
/* Brand colors */
--color-brand-red: #de0f1e;
--color-brand-red-hover: #c00d1a;
--color-brand-red-light: #fee2e2;
--color-brand-blue: #053d57;
--color-brand-blue-light: #0a5a7c;
--color-brand-teal: #557f8c;
--color-brand-teal-light: #6a9aa8;
/* Keyframes */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-fade-in: fade-in 0.2s ease-out;
--animate-fade-out: fade-out 0.2s ease-out;
--animate-slide-in-from-top: slide-in-from-top 0.3s ease-out;
--animate-slide-in-from-bottom: slide-in-from-bottom 0.3s ease-out;
}
@keyframes accordion-down {
from { height: 0; }
to { height: var(--radix-accordion-content-height); }
}
@keyframes accordion-up {
from { height: var(--radix-accordion-content-height); }
to { height: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slide-in-from-top {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slide-in-from-bottom {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@layer base {
:root {
/* MOPC Brand Colors - mapped to shadcn/ui variables */
--background: 0 0% 99.5%;
--foreground: 198 85% 18%;
--card: 0 0% 100%;
--card-foreground: 198 85% 18%;
--popover: 0 0% 100%;
--popover-foreground: 198 85% 18%;
/* Primary - MOPC Red */
--primary: 354 90% 47%;
--primary-foreground: 0 0% 100%;
/* Secondary - Warm gray */
--secondary: 30 6% 96%;
--secondary-foreground: 198 85% 18%;
--muted: 30 6% 96%;
--muted-foreground: 30 8% 45%;
/* Accent - MOPC Teal */
--accent: 194 25% 44%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 30 6% 91%;
--input: 30 6% 91%;
--ring: 354 90% 47%;
--radius: 0.5rem;
/* Semantic colors */
--success: 142 76% 36%;
--warning: 38 92% 50%;
--info: 194 25% 44%;
}
.dark {
--background: 198 85% 8%;
--foreground: 0 0% 98%;
--card: 198 85% 10%;
--card-foreground: 0 0% 98%;
--popover: 198 85% 10%;
--popover-foreground: 0 0% 98%;
--primary: 354 90% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 198 30% 18%;
--secondary-foreground: 0 0% 98%;
--muted: 198 30% 18%;
--muted-foreground: 0 0% 64%;
--accent: 194 25% 50%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 198 30% 22%;
--input: 198 30% 22%;
--ring: 354 90% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus styles */
*:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
.leaflet-container:focus,
.leaflet-container:focus-visible {
outline: none !important;
box-shadow: none !important;
ring: none;
--tw-ring-shadow: none;
--tw-ring-offset-shadow: none;
}
/* Selection color */
::selection {
@apply bg-primary/20 text-foreground;
}
}
@layer utilities {
/* Hide scrollbar but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Text balance for better typography */
.text-balance {
text-wrap: balance;
}
/* Animation utilities */
.animate-in {
animation: fade-in 0.2s ease-out, slide-in-from-bottom 0.3s ease-out;
}
/* Container for admin/jury views */
.container-app {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
}
/* Custom scrollbar for non-hidden areas */
@media (min-width: 768px) {
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
}

39
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
export const metadata: Metadata = {
title: {
default: 'MOPC Platform',
template: '%s | MOPC',
},
description: 'Monaco Ocean Protection Challenge - Jury Voting Platform',
icons: {
icon: '/favicon.ico',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
<Toaster
position="top-right"
toastOptions={{
style: {
background: 'hsl(var(--background))',
color: 'hsl(var(--foreground))',
border: '1px solid hsl(var(--border))',
},
}}
/>
</body>
</html>
)
}

12
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react'
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
)
}

18
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { FileQuestion } from 'lucide-react'
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
<FileQuestion className="h-16 w-16 text-muted-foreground/50" />
<h1 className="text-2xl font-semibold">Page Not Found</h1>
<p className="text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Button asChild>
<Link href="/">Go Home</Link>
</Button>
</div>
)
}

176
src/app/page.tsx Normal file
View File

@@ -0,0 +1,176 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Image from 'next/image'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import type { Route } from 'next'
export const metadata: Metadata = { title: 'Monaco Ocean Protection Challenge' }
export default async function HomePage() {
const session = await auth()
// Redirect authenticated users to their appropriate dashboard
if (session?.user) {
if (
session.user.role === 'SUPER_ADMIN' ||
session.user.role === 'PROGRAM_ADMIN'
) {
redirect('/admin')
} else if (session.user.role === 'JURY_MEMBER') {
redirect('/jury')
} else if (session.user.role === 'MENTOR') {
redirect('/mentor' as Route)
} else if (session.user.role === 'OBSERVER') {
redirect('/observer')
}
}
return (
<div className="flex min-h-screen flex-col">
{/* Header */}
<header className="border-b border-border bg-white">
<div className="container-app flex h-16 items-center justify-between">
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC - Monaco Ocean Protection Challenge"
width={140}
height={45}
className="h-10 w-auto"
priority
/>
<Link
href="/login"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Sign In
</Link>
</div>
</header>
{/* Hero Section */}
<main className="flex flex-1 flex-col">
<section className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
<h1 className="text-display-lg text-brand-blue">
Monaco Ocean Protection Challenge
</h1>
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
Supporting innovative solutions for ocean conservation through fair
and transparent project evaluation.
</p>
<div className="mt-8 flex gap-4">
<Link
href="/login"
className="inline-flex h-12 items-center justify-center rounded-md bg-primary px-8 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Jury Portal
</Link>
<a
href="https://monaco-opc.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-12 items-center justify-center rounded-md border border-border bg-background px-8 text-base font-medium transition-colors hover:bg-muted"
>
Learn More
</a>
</div>
</section>
{/* Features Section */}
<section className="border-t border-border bg-muted/30 px-4 py-16">
<div className="container-app">
<h2 className="text-center text-heading text-brand-blue">
Platform Features
</h2>
<div className="mt-12 grid gap-8 md:grid-cols-3">
<div className="rounded-lg border border-border bg-white p-6">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<svg
className="h-6 w-6 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 className="text-subheading text-brand-blue">
Secure Evaluation
</h3>
<p className="mt-2 text-small text-muted-foreground">
Jury members access only their assigned projects with complete
confidentiality.
</p>
</div>
<div className="rounded-lg border border-border bg-white p-6">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-accent/10">
<svg
className="h-6 w-6 text-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 className="text-subheading text-brand-blue">
Real-time Progress
</h3>
<p className="mt-2 text-small text-muted-foreground">
Track evaluation progress and manage voting windows with
comprehensive dashboards.
</p>
</div>
<div className="rounded-lg border border-border bg-white p-6">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-brand-teal/10">
<svg
className="h-6 w-6 text-brand-teal"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<h3 className="text-subheading text-brand-blue">
Mobile First
</h3>
<p className="mt-2 text-small text-muted-foreground">
Evaluate projects anywhere with a fully responsive design
optimized for all devices.
</p>
</div>
</div>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t border-border bg-brand-blue py-8 text-white">
<div className="container-app text-center">
<p className="text-small">
© {new Date().getFullYear()} Monaco Ocean Protection Challenge. All
rights reserved.
</p>
</div>
</footer>
</div>
)
}

58
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,58 @@
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import { trpc } from '@/lib/trpc/client'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
function getBaseUrl() {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return `http://localhost:${process.env.PORT ?? 3000}`
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
MoreHorizontal,
Mail,
UserCog,
Trash2,
Loader2,
} from 'lucide-react'
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
}
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
const router = useRouter()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation()
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
const handleDelete = async () => {
try {
await deleteUser.mutateAsync({ id: userId })
toast.success('User deleted successfully')
setShowDeleteDialog(false)
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete user')
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/users/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {userEmail}? This action cannot be
undone and will remove all their assignments and evaluations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
setIsSending(true)
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {
setIsSending(false)
}
}
return (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/users/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface CriteriaScoreData {
id: string
name: string
averageScore: number
count: number
}
interface CriteriaScoresProps {
data: CriteriaScoreData[]
}
// Color scale from red to green based on score
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
const formattedData = data.map((d) => ({
...d,
displayName:
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
}))
const overallAverage =
data.length > 0
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Score by Evaluation Criteria</span>
<span className="text-sm font-normal text-muted-foreground">
Overall Avg: {overallAverage.toFixed(2)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="displayName"
tick={{ fontSize: 11 }}
angle={-45}
textAnchor="end"
interval={0}
height={60}
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [
(value ?? 0).toFixed(2),
'Average Score',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as CriteriaScoreData
return `${item.name} (${item.count} ratings)`
}
return ''
}}
/>
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
{formattedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getScoreColor(entry.averageScore)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
ComposedChart,
Bar,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface TimelineDataPoint {
date: string
daily: number
cumulative: number
}
interface EvaluationTimelineProps {
data: TimelineDataPoint[]
}
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
// Format date for display
const formattedData = data.map((d) => ({
...d,
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
}))
const totalEvaluations =
data.length > 0 ? data[data.length - 1].cumulative : 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Evaluation Progress Over Time</span>
<span className="text-sm font-normal text-muted-foreground">
Total: {totalEvaluations} evaluations
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={formattedData}
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="dateFormatted"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
]}
labelFormatter={(label) => `Date: ${label}`}
/>
<Legend />
<Bar
yAxisId="left"
dataKey="daily"
name="Daily Evaluations"
fill="#8884d8"
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="cumulative"
name="Cumulative Total"
stroke="#82ca9d"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 6 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import dynamic from 'next/dynamic'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getCountryName } from '@/lib/countries'
import { Globe, MapPin } from 'lucide-react'
type CountryData = { countryCode: string; count: number }
type GeographicDistributionProps = {
data: CountryData[]
compact?: boolean
}
const LeafletMap = dynamic(() => import('./leaflet-map'), {
ssr: false,
loading: () => (
<div
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
style={{ height: 400 }}
>
<Globe className="h-8 w-8 text-muted-foreground/40" />
</div>
),
})
const LeafletMapFull = dynamic(() => import('./leaflet-map'), {
ssr: false,
loading: () => (
<div
className="flex items-center justify-center bg-muted/30 rounded-md animate-pulse"
style={{ height: 500 }}
>
<Globe className="h-8 w-8 text-muted-foreground/40" />
</div>
),
})
export function GeographicDistribution({
data,
compact = false,
}: GeographicDistributionProps) {
const validData = data.filter((d) => d.countryCode !== 'UNKNOWN')
const unknownCount = data
.filter((d) => d.countryCode === 'UNKNOWN')
.reduce((sum, d) => sum + d.count, 0)
const totalProjects = data.reduce((sum, d) => sum + d.count, 0)
const countryCount = validData.length
if (data.length === 0 || totalProjects === 0) {
return compact ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Globe className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No geographic data available
</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Project Origins
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/30" />
<p className="mt-3 text-muted-foreground">
No geographic data available yet
</p>
</div>
</CardContent>
</Card>
)
}
if (compact) {
return (
<div className="space-y-3">
<LeafletMap data={validData} compact />
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>{countryCount} countries</span>
{unknownCount > 0 && (
<span>{unknownCount} projects without country</span>
)}
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Project Origins
</span>
<span className="text-sm font-normal text-muted-foreground">
{countryCount} countries &middot; {totalProjects} projects
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LeafletMapFull data={validData} />
{unknownCount > 0 && (
<p className="text-xs text-muted-foreground">
{unknownCount} project{unknownCount !== 1 ? 's' : ''} without country data
</p>
)}
{/* Top countries table */}
<div className="space-y-1">
<p className="text-sm font-medium">Top Countries</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
{validData
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.map((d) => (
<div
key={d.countryCode}
className="flex items-center justify-between py-1 border-b border-border/50"
>
<span className="text-muted-foreground">
{getCountryName(d.countryCode)}
</span>
<span className="font-medium tabular-nums">{d.count}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { GeographicDistribution } from './geographic-distribution'
import { Globe } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
type GeographicSummaryCardProps = {
programId: string
}
export function GeographicSummaryCard({ programId }: GeographicSummaryCardProps) {
const { data, isLoading } = trpc.analytics.getGeographicDistribution.useQuery(
{ programId },
{ enabled: !!programId }
)
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<Skeleton className="h-[250px] w-full rounded-md" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Project Origins
</CardTitle>
<CardDescription>Geographic distribution of projects</CardDescription>
</CardHeader>
<CardContent>
<GeographicDistribution data={data || []} compact />
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,8 @@
export { ScoreDistributionChart } from './score-distribution'
export { EvaluationTimelineChart } from './evaluation-timeline'
export { StatusBreakdownChart } from './status-breakdown'
export { JurorWorkloadChart } from './juror-workload'
export { ProjectRankingsChart } from './project-rankings'
export { CriteriaScoresChart } from './criteria-scores'
export { GeographicDistribution } from './geographic-distribution'
export { GeographicSummaryCard } from './geographic-summary-card'

View File

@@ -0,0 +1,102 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface JurorWorkloadData {
id: string
name: string
assigned: number
completed: number
completionRate: number
}
interface JurorWorkloadProps {
data: JurorWorkloadData[]
}
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
// Truncate names for display
const formattedData = data.map((d) => ({
...d,
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
}))
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
const overallRate =
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Juror Workload</span>
<span className="text-sm font-normal text-muted-foreground">
{overallRate}% overall completion
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedData}
layout="vertical"
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" />
<YAxis
dataKey="displayName"
type="category"
width={90}
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
value ?? 0,
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as JurorWorkloadData
return `${item.name} (${item.completionRate}% complete)`
}
return ''
}}
/>
<Legend />
<Bar
dataKey="assigned"
name="Assigned"
fill="#8884d8"
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="completed"
name="Completed"
fill="#82ca9d"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useMemo } from 'react'
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import { COUNTRIES } from '@/lib/countries'
type CountryData = { countryCode: string; count: number }
type LeafletMapProps = {
data: CountryData[]
compact?: boolean
}
export default function LeafletMap({ data, compact }: LeafletMapProps) {
const markers = useMemo(() => {
const maxCount = Math.max(...data.map((d) => d.count), 1)
return data
.filter((d) => d.countryCode !== 'UNKNOWN' && COUNTRIES[d.countryCode])
.map((d) => {
const country = COUNTRIES[d.countryCode]
const ratio = d.count / maxCount
const radius = 5 + ratio * 15
return {
code: d.countryCode,
name: country.name,
position: [country.lat, country.lng] as [number, number],
count: d.count,
radius,
ratio,
}
})
}, [data])
return (
<MapContainer
center={[20, 0]}
zoom={compact ? 1 : 2}
scrollWheelZoom
zoomControl
dragging
doubleClickZoom
style={{
height: compact ? 400 : 500,
width: '100%',
borderRadius: '0.5rem',
background: '#f0f0f0',
}}
attributionControl={false}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
/>
{markers.map((marker) => (
<CircleMarker
key={marker.code}
center={marker.position}
radius={marker.radius}
pathOptions={{
color: '#de0f1e',
fillColor: '#de0f1e',
fillOpacity: 0.35 + marker.ratio * 0.45,
weight: 1.5,
}}
>
<Tooltip direction="top" offset={[0, -marker.radius]}>
<div className="text-xs font-medium">
<span className="font-semibold">{marker.name}</span>
<br />
{marker.count} project{marker.count !== 1 ? 's' : ''}
</div>
</Tooltip>
</CircleMarker>
))}
</MapContainer>
)
}

View File

@@ -0,0 +1,121 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ProjectRankingData {
id: string
title: string
teamName: string | null
status: string
averageScore: number | null
evaluationCount: number
}
interface ProjectRankingsProps {
data: ProjectRankingData[]
limit?: number
}
// Generate color based on score (red to green gradient)
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function ProjectRankingsChart({
data,
limit = 20,
}: ProjectRankingsProps) {
const displayData = data.slice(0, limit).map((d, index) => ({
...d,
rank: index + 1,
displayTitle:
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
score: d.averageScore || 0,
}))
const averageScore =
data.length > 0
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
: 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Project Rankings</span>
<span className="text-sm font-normal text-muted-foreground">
Top {displayData.length} of {data.length} projects
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[500px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={displayData}
layout="vertical"
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" domain={[0, 10]} />
<YAxis
dataKey="displayTitle"
type="category"
width={140}
tick={{ fontSize: 11 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as ProjectRankingData & {
rank: number
}
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
}
return ''
}}
/>
<ReferenceLine
x={averageScore}
stroke="#666"
strokeDasharray="5 5"
label={{
value: `Avg: ${averageScore.toFixed(1)}`,
position: 'top',
fill: '#666',
fontSize: 11,
}}
/>
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
{displayData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ScoreDistributionProps {
data: { score: number; count: number }[]
averageScore: number
totalScores: number
}
const COLORS = [
'#de0f1e', // 1 - red (poor)
'#e6382f',
'#ed6141',
'#f38a52',
'#f8b364', // 5 - yellow (average)
'#c9c052',
'#99cc41',
'#6ad82f',
'#3be31e',
'#0bd90f', // 10 - green (excellent)
]
export function ScoreDistributionChart({
data,
averageScore,
totalScores,
}: ScoreDistributionProps) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Score Distribution</span>
<span className="text-sm font-normal text-muted-foreground">
Avg: {averageScore.toFixed(2)} ({totalScores} scores)
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="score"
label={{
value: 'Score',
position: 'insideBottom',
offset: -10,
}}
/>
<YAxis
label={{
value: 'Count',
angle: -90,
position: 'insideLeft',
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
labelFormatter={(label) => `Score: ${label}`}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,131 @@
'use client'
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StatusDataPoint {
status: string
count: number
}
interface StatusBreakdownProps {
data: StatusDataPoint[]
}
const STATUS_COLORS: Record<string, string> = {
PENDING: '#8884d8',
UNDER_REVIEW: '#82ca9d',
SHORTLISTED: '#ffc658',
SEMIFINALIST: '#ff7300',
FINALIST: '#00C49F',
WINNER: '#0088FE',
ELIMINATED: '#de0f1e',
WITHDRAWN: '#999999',
}
const renderCustomLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: {
cx?: number
cy?: number
midAngle?: number
innerRadius?: number
outerRadius?: number
percent?: number
}) => {
if (cx === undefined || cy === undefined || midAngle === undefined ||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
return null
}
if (percent < 0.05) return null // Don't show labels for small slices
const RADIAN = Math.PI / 180
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
const x = cx + radius * Math.cos(-midAngle * RADIAN)
const y = cy + radius * Math.sin(-midAngle * RADIAN)
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight={600}
>
{`${(percent * 100).toFixed(0)}%`}
</text>
)
}
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
const total = data.reduce((sum, item) => sum + item.count, 0)
// Format status for display
const formattedData = data.map((d) => ({
...d,
name: d.status.replace(/_/g, ' '),
color: STATUS_COLORS[d.status] || '#8884d8',
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Project Status Distribution</span>
<span className="text-sm font-normal text-muted-foreground">
{total} projects
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={formattedData}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomLabel}
outerRadius={100}
innerRadius={50}
fill="#8884d8"
dataKey="count"
nameKey="name"
>
{formattedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined, name: string | undefined) => [
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
name ?? '',
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

Some files were not shown because too many files have changed in this diff Show More