Remove dynamic form builder and complete RoundProject→roundId migration

Major cleanup and schema migration:
- Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.)
- Complete migration from RoundProject junction table to direct Project.roundId
- Add sortOrder and entryNotificationType fields to Round model
- Add country field to User model for mentor matching
- Enhance onboarding with profile photo and country selection steps
- Fix all TypeScript errors related to roundProjects references
- Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul)

Files removed:
- admin/forms/* pages and related components
- admin/onboarding/* pages
- applicationForm.ts and onboarding.ts routers
- Dynamic form builder Prisma models and enums

Schema changes:
- Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models
- Removed FormFieldType and SpecialFieldType enums
- Added Round.sortOrder, Round.entryNotificationType
- Added User.country

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View File

@@ -1,241 +0,0 @@
'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

@@ -1,83 +0,0 @@
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

@@ -1,130 +0,0 @@
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

@@ -1,135 +0,0 @@
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

@@ -1,131 +0,0 @@
'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

@@ -1,152 +0,0 @@
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

@@ -329,7 +329,7 @@ export default function MemberDetailPage() {
</TableCell>
<TableCell>
<Badge variant="secondary">
{assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'}
{assignment.project.status ?? 'SUBMITTED'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">

View File

@@ -31,6 +31,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
ArrowRight,
@@ -42,12 +48,19 @@ import {
Plus,
FileSpreadsheet,
UserPlus,
FolderKanban,
ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
roundId: string
}
interface MemberRow {
id: string
name: string
@@ -55,6 +68,7 @@ interface MemberRow {
role: Role
expertiseTags: string[]
tagInput: string
assignments: Assignment[]
}
interface ParsedUser {
@@ -62,6 +76,7 @@ interface ParsedUser {
name?: string
role: Role
expertiseTags?: string[]
assignments?: Assignment[]
isValid: boolean
error?: string
isDuplicate?: boolean
@@ -81,7 +96,7 @@ function nextRowId(): string {
}
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' }
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '', assignments: [] }
}
// Common expertise tags for suggestions
@@ -115,8 +130,12 @@ export default function MemberInvitePage() {
const [result, setResult] = useState<{
created: number
skipped: number
assignmentsCreated?: number
} | null>(null)
// Pre-assignment state
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
@@ -125,6 +144,33 @@ export default function MemberInvitePage() {
},
})
// Fetch programs with rounds for pre-assignment
const { data: programsData } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Flatten all rounds from all programs
const rounds = useMemo(() => {
if (!programsData) return []
type ProgramWithRounds = typeof programsData[number] & {
rounds?: Array<{ id: string; name: string }>
}
return (programsData as ProgramWithRounds[]).flatMap((program) =>
(program.rounds || []).map((round) => ({
id: round.id,
name: round.name,
programName: `${program.name} ${program.year}`,
}))
)
}, [programsData])
// Fetch projects for selected round
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
{ roundId: selectedRoundId, perPage: 200 },
{ enabled: !!selectedRoundId }
)
const projects = projectsData?.projects || []
// --- Manual entry helpers ---
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
setRows((prev) =>
@@ -176,6 +222,28 @@ export default function MemberInvitePage() {
).slice(0, 5)
}
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== rowId) return r
const existing = r.assignments.find((a) => a.projectId === projectId)
if (existing) {
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
} else {
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
}
})
)
}
const clearRowAssignments = (rowId: string) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, assignments: [] } : r))
)
}
// --- CSV helpers ---
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -252,6 +320,7 @@ export default function MemberInvitePage() {
name: r.name.trim() || undefined,
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isDuplicate,
error: !isValidFormat
@@ -298,6 +367,7 @@ export default function MemberInvitePage() {
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
assignments: u.assignments,
})),
})
setSendProgress(100)
@@ -362,6 +432,35 @@ export default function MemberInvitePage() {
{inputMethod === 'manual' ? (
<div className="space-y-4">
{/* Round selector for pre-assignments */}
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
<div className="flex items-center gap-3">
<FolderKanban className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
<p className="text-xs text-muted-foreground">
Select a round to assign projects to jury members before they onboard
</p>
</div>
<Select
value={selectedRoundId || 'none'}
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No pre-assignment</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Member cards */}
{rows.map((row, index) => (
<div
@@ -500,6 +599,81 @@ export default function MemberInvitePage() {
</div>
)}
</div>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && (
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-between text-muted-foreground hover:text-foreground"
>
<span className="flex items-center gap-2">
<FolderKanban className="h-4 w-4" />
Pre-assign Projects
{row.assignments.length > 0 && (
<Badge variant="secondary" className="ml-1">
{row.assignments.length}
</Badge>
)}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2">
{projectsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading projects...</span>
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No projects in this round
</p>
) : (
<div className="max-h-48 overflow-y-auto space-y-1 border rounded-lg p-2">
{projects.map((project) => {
const isAssigned = row.assignments.some(
(a) => a.projectId === project.id
)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted',
isAssigned && 'bg-primary/5'
)}
>
<Checkbox
checked={isAssigned}
onCheckedChange={() =>
toggleProjectAssignment(row.id, project.id)
}
/>
<span className="text-sm truncate flex-1">
{project.title}
</span>
</label>
)
})}
</div>
)}
{row.assignments.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => clearRowAssignments(row.id)}
className="text-xs text-muted-foreground"
>
Clear all assignments
</Button>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
))}
@@ -722,6 +896,9 @@ export default function MemberInvitePage() {
{result?.skipped
? ` ${result.skipped} skipped (already exist).`
: ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
: ''}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>

View File

@@ -1,686 +0,0 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { 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 {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Loader2,
Save,
Plus,
GripVertical,
Trash2,
Settings,
Eye,
Mail,
Link2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Sortable step item component
function SortableStep({
step,
isSelected,
onSelect,
onDelete,
}: {
step: { id: string; name: string; title: string; fields: unknown[] }
isSelected: boolean
onSelect: () => void
onDelete: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors',
isSelected ? 'border-primary bg-primary/5' : 'border-transparent hover:bg-muted',
isDragging && 'opacity-50'
)}
onClick={onSelect}
>
<button
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{step.title}</p>
<p className="text-xs text-muted-foreground">
{(step.fields as unknown[]).length} fields
</p>
</div>
<button
className="p-1 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
function OnboardingFormEditor({ formId }: { formId: string }) {
const router = useRouter()
const utils = trpc.useUtils()
// Fetch form data with steps
const { data: form, isLoading } = trpc.applicationForm.getForBuilder.useQuery(
{ id: formId },
{ refetchOnWindowFocus: false }
)
// Local state for editing
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Mutations
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Form updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const createStep = trpc.applicationForm.createStep.useMutation({
onSuccess: (data) => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(data.id)
toast.success('Step created')
},
})
const updateStep = trpc.applicationForm.updateStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Step updated')
},
})
const deleteStep = trpc.applicationForm.deleteStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(null)
toast.success('Step deleted')
},
})
const reorderSteps = trpc.applicationForm.reorderSteps.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
},
})
const updateEmailSettings = trpc.applicationForm.updateEmailSettings.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Email settings updated')
},
})
const linkToRound = trpc.applicationForm.linkToRound.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Round linked')
},
})
// Fetch available rounds
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery({
programId: form?.programId || undefined,
})
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleStepDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id || !form) return
const oldIndex = form.steps.findIndex((s) => s.id === active.id)
const newIndex = form.steps.findIndex((s) => s.id === over.id)
const newOrder = arrayMove(form.steps, oldIndex, newIndex)
reorderSteps.mutate({
formId,
stepIds: newOrder.map((s) => s.id),
})
}
const handleAddStep = () => {
const stepNumber = (form?.steps.length || 0) + 1
createStep.mutate({
formId,
step: {
name: `step_${stepNumber}`,
title: `Step ${stepNumber}`,
},
})
}
const selectedStep = form?.steps.find((s) => s.id === selectedStepId)
if (isLoading) {
return <FormEditorSkeleton />
}
if (!form) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Form not found</p>
<Link href="/admin/onboarding">
<Button variant="outline" className="mt-4">
Back to Onboarding
</Button>
</Link>
</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/onboarding">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{form.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={form.status === 'PUBLISHED' ? 'default' : 'secondary'}>
{form.status}
</Badge>
{form.round && (
<Badge variant="outline">
<Link2 className="mr-1 h-3 w-3" />
{form.round.name}
</Badge>
)}
</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>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList>
<TabsTrigger value="steps">Steps & Fields</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="emails">Emails</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Steps List */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-base">Steps</CardTitle>
<CardDescription>
Drag to reorder wizard steps
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={form.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{form.steps.map((step) => (
<SortableStep
key={step.id}
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => setSelectedStepId(step.id)}
onDelete={() => {
if (confirm('Delete this step? Fields will be unassigned.')) {
deleteStep.mutate({ id: step.id })
}
}}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={handleAddStep}
disabled={createStep.isPending}
>
{createStep.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add Step
</Button>
</CardContent>
</Card>
{/* Step Editor */}
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'}
</CardTitle>
</CardHeader>
<CardContent>
{selectedStep ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Step Title</Label>
<Input
value={selectedStep.title}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { title: e.target.value },
})
}}
/>
</div>
<div className="space-y-2">
<Label>Internal Name</Label>
<Input
value={selectedStep.name}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { name: e.target.value },
})
}}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Textarea
value={selectedStep.description || ''}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { description: e.target.value },
})
}}
rows={3}
/>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Fields in this step</h4>
{selectedStep.fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
No fields yet. Use the existing form editor to add fields.
</p>
) : (
<div className="space-y-2">
{selectedStep.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</p>
</div>
</div>
))}
</div>
)}
<Link href={`/admin/forms/${formId}`}>
<Button variant="outline" size="sm" className="mt-4">
Edit Fields in Form Editor
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
Select a step from the list to edit it
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Name</Label>
<Input
value={form.name}
onChange={(e) => {
updateForm.mutate({ id: formId, name: e.target.value })
}}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, description: e.target.value || null })
}}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
value={form.publicSlug || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, publicSlug: e.target.value || null })
}}
placeholder="your-form-slug"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4">
<div>
<Label>Public Access</Label>
<p className="text-sm text-muted-foreground">
Allow public submissions to this form
</p>
</div>
<Switch
checked={form.isPublic}
onCheckedChange={(checked) => {
updateForm.mutate({ id: formId, isPublic: checked })
}}
/>
</div>
<div className="space-y-2 pt-4">
<Label>Status</Label>
<Select
value={form.status}
onValueChange={(value) => {
updateForm.mutate({ id: formId, status: value as 'DRAFT' | 'PUBLISHED' | 'CLOSED' })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Round Linking</CardTitle>
<CardDescription>
Link this form to a round to create projects on submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Linked Round</Label>
<Select
value={form.roundId || 'none'}
onValueChange={(value) => {
linkToRound.mutate({
formId,
roundId: value === 'none' ? null : value,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No round linked</SelectItem>
{form.round && (
<SelectItem value={form.round.id}>
{form.round.name} (current)
</SelectItem>
)}
{availableRounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program?.name} {round.program?.year} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Emails Tab */}
<TabsContent value="emails" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Email Notifications</CardTitle>
<CardDescription>
Configure emails sent when applications are submitted
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Confirmation Email</Label>
<p className="text-sm text-muted-foreground">
Send a confirmation email to the applicant
</p>
</div>
<Switch
checked={form.sendConfirmationEmail}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendConfirmationEmail: checked,
})
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Team Invite Emails</Label>
<p className="text-sm text-muted-foreground">
Send invite emails to team members
</p>
</div>
<Switch
checked={form.sendTeamInviteEmails}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendTeamInviteEmails: checked,
})
}}
/>
</div>
{form.sendConfirmationEmail && (
<>
<div className="space-y-2 pt-4 border-t">
<Label>Custom Email Subject (optional)</Label>
<Input
value={form.confirmationEmailSubject || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailSubject: e.target.value || null,
})
}}
placeholder="Application Received - {projectName}"
/>
</div>
<div className="space-y-2">
<Label>Custom Email Message (optional)</Label>
<Textarea
value={form.confirmationEmailBody || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailBody: e.target.value || null,
})
}}
placeholder="Add a custom message to include in the confirmation email..."
rows={4}
/>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function FormEditorSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-24" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
</div>
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-96" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
export default function OnboardingFormPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<FormEditorSkeleton />}>
<OnboardingFormEditor formId={id} />
</Suspense>
)
}

View File

@@ -1,171 +0,0 @@
'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 NewOnboardingFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
// Fetch programs for selection
const { data: programs } = trpc.program.list.useQuery({})
// Fetch available rounds for the selected program
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery(
{ programId: selectedProgramId || undefined },
{ enabled: true }
)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Onboarding form created successfully')
router.push(`/admin/onboarding/${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: selectedProgramId || null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/onboarding">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Onboarding Form</h1>
<p className="text-muted-foreground">
Set up a new application wizard for project submissions
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Configure the basic settings for your onboarding 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., MOPC 2026 Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Edition / Program</Label>
<Select
value={selectedProgramId}
onValueChange={setSelectedProgramId}
>
<SelectTrigger>
<SelectValue placeholder="Select a program (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No program</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Link to a specific edition to enable project creation
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this application 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., mopc-2026"
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/onboarding">
<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

@@ -1,153 +0,0 @@
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,
Link2,
} from 'lucide-react'
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 OnboardingFormsList() {
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 onboarding forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application wizard to accept project submissions
</p>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Onboarding 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>
{form.program && (
<Badge variant="outline">{form.program.name} {form.program.year}</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/onboarding/${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 OnboardingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Onboarding</h1>
<p className="text-muted-foreground">
Configure application wizards for project submissions
</p>
</div>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<OnboardingFormsList />
</Suspense>
</div>
)
}

View File

@@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId },
}),
prisma.project.count({
where: { programId: editionId },
where: { round: { programId: editionId } },
}),
prisma.project.count({
where: {
programId: editionId,
round: { programId: editionId },
createdAt: { gte: sevenDaysAgo },
},
}),
@@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
include: {
_count: {
select: {
roundProjects: true,
projects: true,
assignments: true,
},
},
@@ -161,7 +161,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
},
}),
prisma.project.findMany({
where: { programId: editionId },
where: { round: { programId: editionId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
@@ -174,20 +174,18 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
logoKey: true,
createdAt: true,
submittedAt: true,
roundProjects: {
select: { status: true, round: { select: { name: true } } },
take: 1,
},
status: true,
round: { select: { name: true } },
},
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { programId: editionId },
where: { round: { programId: editionId } },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { programId: editionId },
where: { round: { programId: editionId } },
_count: true,
}),
])
@@ -394,7 +392,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.roundProjects} projects &middot; {round._count.assignments} assignments
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
@@ -461,10 +459,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'}
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">

View File

@@ -130,7 +130,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
{round.status}
</Badge>
</TableCell>
<TableCell>{round._count.roundProjects}</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow>

View File

@@ -121,7 +121,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundProjects?.[0]?.round?.id,
roundId: project?.roundId ?? undefined,
})
// Mutations
@@ -167,7 +167,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: (project.roundProjects?.[0]?.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
@@ -202,7 +202,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
roundId: project?.roundProjects?.[0]?.round?.id,
roundId: project?.roundId ?? undefined,
tags: data.tags,
})
}

View File

@@ -160,7 +160,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<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) => (
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>

View File

@@ -140,18 +140,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{project.roundProjects?.length > 0 ? (
project.roundProjects.map((rp, i) => (
<span key={rp.round.id} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/50">/</span>}
<Link
href={`/admin/rounds/${rp.round.id}`}
className="hover:underline"
>
{rp.round.name}
</Link>
</span>
))
{project.roundId ? (
<Link
href={`/admin/rounds/${project.roundId}`}
className="hover:underline"
>
{project.round?.name ?? 'Round'}
</Link>
) : (
<span>No round</span>
)}
@@ -160,8 +155,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
{project.teamName && (
@@ -513,7 +508,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundProjects?.[0]?.round?.id}/assignments`}>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
Manage
</Link>
</Button>

View File

@@ -121,7 +121,6 @@ function NewProjectPageContent() {
})
createProject.mutate({
programId: selectedRound!.programId,
roundId: selectedRoundId,
title: title.trim(),
teamName: teamName.trim() || undefined,

View File

@@ -359,7 +359,7 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.roundProjects?.[0]?.status === 'REJECTED'
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
@@ -388,15 +388,15 @@ export default function ProjectsPage() {
<TableCell>
<div>
<div className="flex items-center gap-2">
<p>{project.roundProjects?.[0]?.round?.name ?? '-'}</p>
{project.roundProjects?.[0]?.status === 'REJECTED' && (
<p>{project.round?.name ?? '-'}</p>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.program?.name}
{project.round?.program?.name}
</p>
</div>
</TableCell>
@@ -409,9 +409,9 @@ export default function ProjectsPage() {
</TableCell>
<TableCell>
<Badge
variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
@@ -478,11 +478,11 @@ export default function ProjectsPage() {
</CardTitle>
<Badge
variant={
statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'
statusColors[project.status ?? 'SUBMITTED'] || 'secondary'
}
className="shrink-0"
>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')}
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
@@ -493,8 +493,8 @@ export default function ProjectsPage() {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2">
<span>{project.roundProjects?.[0]?.round?.name ?? '-'}</span>
{project.roundProjects?.[0]?.status === 'REJECTED' && (
<span>{project.round?.name ?? '-'}</span>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>

View File

@@ -72,7 +72,7 @@ export interface ProjectFilters {
}
interface FilterOptions {
rounds: Array<{ id: string; name: string; sortOrder: number; program: { name: string; year: number } }>
rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>

View File

@@ -84,7 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
const [formInitialized, setFormInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
// entryNotificationType removed from schema
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
@@ -138,7 +138,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
// Set round type, settings, and notification type
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
setEntryNotificationType(round.entryNotificationType || '')
setFormInitialized(true)
}
}, [round, form, formInitialized])
@@ -166,7 +165,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null,
votingEndAt: data.votingEndAt ?? null,
entryNotificationType: entryNotificationType || null,
})
// Update evaluation form if criteria changed and no evaluations exist
@@ -353,38 +351,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Team Notification */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Team Notification - removed from schema, feature not implemented */}
{/* Evaluation Criteria */}
<Card>

View File

@@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
if (storedOrder.length > 0) {
setProjectOrder(storedOrder)
} else {
setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id))
setProjectOrder(sessionData.round.projects.map((p) => p.id))
}
}
}, [sessionData])
@@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
)
}
const projects = sessionData.round.roundProjects.map((rp) => rp.project)
const projects = sessionData.round.projects
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p)

View File

@@ -367,7 +367,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
<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>

View File

@@ -75,7 +75,7 @@ type RoundData = {
votingStartAt: string | null
votingEndAt: string | null
_count?: {
roundProjects: number
projects: number
assignments: number
}
}
@@ -238,7 +238,7 @@ function ProgramRounds({ program }: { program: any }) {
{round.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.roundProjects || 0}
{round._count?.projects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
@@ -425,7 +425,7 @@ function SortableRoundRow({
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.roundProjects || 0}</span>
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
@@ -509,7 +509,7 @@ function SortableRoundRow({
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.roundProjects || 0} project assignments,{' '}
remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>