Add dynamic apply wizard customization with admin settings UI

- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
  option management, feature toggles, welcome message customization, and
  custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
  mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
  field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
  admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
  MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
  to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
  safe area insets for notched phones, buildStepsArray field visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 13:18:20 +01:00
parent 98fe658c33
commit e7c86a7b1b
40 changed files with 4477 additions and 1045 deletions

View File

@@ -69,7 +69,7 @@ import {
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
@@ -99,6 +99,7 @@ interface ParsedUser {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@@ -265,6 +266,11 @@ export default function MemberInvitePage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
// Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table when navigating back
@@ -393,19 +399,22 @@ export default function MemberInvitePage() {
const name = nameKey ? row[nameKey]?.trim() : undefined
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
const role: Role =
rawRole === 'MENTOR'
rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
return {
email,
name,
role,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !email
? 'No email found'
@@ -413,6 +422,8 @@ export default function MemberInvitePage() {
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -421,7 +432,7 @@ export default function MemberInvitePage() {
},
})
},
[]
[isSuperAdmin]
)
// --- Parse manual rows into ParsedUser format ---
@@ -433,6 +444,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate) seenEmails.add(email)
return {
email,
@@ -440,12 +452,14 @@ export default function MemberInvitePage() {
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -524,6 +538,11 @@ export default function MemberInvitePage() {
<CardTitle>Invite Members</CardTitle>
<CardDescription>
Add members individually or upload a CSV file
{isSuperAdmin && (
<span className="block mt-1 text-primary font-medium">
As a super admin, you can also invite program admins
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -627,6 +646,11 @@ export default function MemberInvitePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">
Program Admin
</SelectItem>
)}
<SelectItem value="JURY_MEMBER">
Jury Member
</SelectItem>

File diff suppressed because it is too large Load Diff

View File

@@ -45,18 +45,23 @@ export default function EditProgramPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
status: 'DRAFT',
applyMode: 'round' as 'edition' | 'round' | 'both',
})
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
useEffect(() => {
if (program) {
const settings = (program.settingsJson as Record<string, any>) || {}
setFormData({
name: program.name,
slug: program.slug || '',
description: program.description || '',
status: program.status,
applyMode: settings.applyMode || 'round',
})
}
}, [program])
@@ -89,8 +94,12 @@ export default function EditProgramPage() {
updateProgram.mutate({
id,
name: formData.name,
slug: formData.slug || undefined,
description: formData.description || undefined,
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
settingsJson: {
applyMode: formData.applyMode,
},
})
}
@@ -196,6 +205,41 @@ export default function EditProgramPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="slug">Edition Slug</Label>
<Input
id="slug"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="e.g., mopc-2026"
/>
<p className="text-xs text-muted-foreground">
URL-friendly identifier for edition-wide applications (optional)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="applyMode">Application Flow</Label>
<Select
value={formData.applyMode}
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edition">Edition-wide only</SelectItem>
<SelectItem value="round">Round-specific only</SelectItem>
<SelectItem value="both">Allow both</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Controls whether applicants apply to the program or specific rounds
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea

View File

@@ -33,6 +33,7 @@ import {
FolderKanban,
Eye,
Pencil,
Wand2,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
@@ -146,6 +147,12 @@ async function ProgramsContent() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -194,6 +201,12 @@ async function ProgramsContent() {
Edit
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -78,9 +78,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
id: projectId,
})
// Fetch files
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch grouped files by round (if project has a roundId)
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
{ projectId, roundId: project?.roundId || '' },
{ enabled: !!project?.roundId }
)
// Fetch available rounds for upload selector (if project has a programId)
const { data: rounds } = trpc.round.listByProgram.useQuery(
{ programId: project?.programId || '' },
{ enabled: !!project?.programId }
)
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
@@ -492,7 +504,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
{groupedFiles && groupedFiles.length > 0 ? (
<FileViewer groupedFiles={groupedFiles} />
) : files && files.length > 0 ? (
<FileViewer
projectId={projectId}
files={files.map((f) => ({
@@ -516,8 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
roundId={project.roundId || undefined}
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
if (project.roundId) {
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
}
}}
/>
</div>

View File

@@ -0,0 +1,345 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
)
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetRoundId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetRoundId,
})
}
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
roundId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation rounds
</p>
</div>
{/* Program Selector */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
</div>
</Card>
{/* Projects Table */}
{selectedProgramId && (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingRounds ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
</Card>
)}
</>
)}
{!selectedProgramId && (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
</Card>
)}
{/* Bulk Assignment Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Round</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetRoundId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}