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