Files
MOPC-App/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx
Matt 04d0deced1
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m24s
Fix build errors: add missing Prisma models/fields and resolve TypeScript type errors
Schema: Add 11 new models (RoundTemplate, MentorNote, MentorMilestone,
MentorMilestoneCompletion, EvaluationDiscussion, DiscussionComment,
Message, MessageRecipient, MessageTemplate, Webhook, WebhookDelivery,
DigestLog) and missing fields on existing models (Project.isDraft,
ProjectFile.version, LiveVotingSession.allowAudienceVotes, User.digestFrequency,
AuditLog.sessionId, MentorAssignment.completionStatus, etc).
Add AUDIT_CONFIG/LOCALIZATION/DIGEST/ANALYTICS enum values.

Code: Fix implicit any types, route type casts, enum casts, null safety,
composite key handling, and relation field names across 11 source files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:04:02 +01:00

1474 lines
50 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import type {
WizardConfig,
WizardStep,
DropdownOption,
CustomField,
WizardStepId,
} from '@/types/wizard-config'
import { DEFAULT_WIZARD_CONFIG, WIZARD_STEP_IDS } from '@/types/wizard-config'
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 { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
GripVertical,
Eye,
EyeOff,
Save,
Loader2,
Plus,
Pencil,
Trash2,
RotateCcw,
Download,
Upload,
} 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'
// ============================================================================
// Sortable Step Row
// ============================================================================
function SortableStepRow({
step,
onToggle,
onTitleChange,
}: {
step: WizardStep
onToggle: (id: WizardStepId, enabled: boolean) => void
onTitleChange: (id: WizardStepId, title: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const isReview = step.id === 'review'
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'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="flex-1 min-w-0">
<Input
value={step.title || ''}
onChange={(e) => onTitleChange(step.id, e.target.value)}
className="h-8 text-sm font-medium"
placeholder={step.id}
/>
</div>
<Badge variant="secondary" className="text-xs shrink-0">
{step.id}
</Badge>
{step.enabled ? (
<Eye className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<EyeOff className="h-4 w-4 text-muted-foreground/40 shrink-0" />
)}
<Switch
checked={step.enabled}
onCheckedChange={(checked) => onToggle(step.id, checked)}
disabled={isReview}
aria-label={`Toggle ${step.title || step.id}`}
/>
</div>
)
}
// ============================================================================
// Sortable Dropdown Option Row
// ============================================================================
function SortableOptionRow({
option,
onEdit,
onDelete,
canDelete,
}: {
option: DropdownOption & { _id: string }
onEdit: () => void
onDelete: () => void
canDelete: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: option._id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'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="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{option.label}</p>
{option.description && (
<p className="text-xs text-muted-foreground truncate">
{option.description}
</p>
)}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{option.value}
</Badge>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={onDelete}
disabled={!canDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ApplySettingsPage() {
const params = useParams<{ id: string }>()
const programId = params.id
// --- Queries ---
const { data: program, isLoading: programLoading } = trpc.program.get.useQuery(
{ id: programId },
{ enabled: !!programId }
)
const { data: serverConfig, isLoading: configLoading } =
trpc.program.getWizardConfig.useQuery(
{ programId },
{ enabled: !!programId }
)
const { data: templates } = trpc.wizardTemplate.list.useQuery(
{ programId },
{ enabled: !!programId }
)
// --- Mutations ---
const createTemplate = trpc.wizardTemplate.create.useMutation({
onSuccess: () => {
toast.success('Template saved')
setSaveTemplateOpen(false)
setSaveTemplateName('')
},
onError: (error) => toast.error(error.message),
})
const updateConfig = trpc.program.updateWizardConfig.useMutation({
onSuccess: () => {
toast.success('Settings saved successfully')
setIsDirty(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to save settings')
},
})
// --- Local State ---
const [config, setConfig] = useState<WizardConfig>(DEFAULT_WIZARD_CONFIG)
const [isDirty, setIsDirty] = useState(false)
const [initialized, setInitialized] = useState(false)
// Dialog states
const [optionDialogOpen, setOptionDialogOpen] = useState(false)
const [optionDialogSection, setOptionDialogSection] = useState<
'categories' | 'oceanIssues'
>('categories')
const [editingOptionIndex, setEditingOptionIndex] = useState<number | null>(null)
const [optionForm, setOptionForm] = useState<DropdownOption>({
value: '',
label: '',
description: '',
icon: '',
})
// Template dialog
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const [saveTemplateName, setSaveTemplateName] = useState('')
// Custom field dialog
const [fieldDialogOpen, setFieldDialogOpen] = useState(false)
const [fieldForm, setFieldForm] = useState<Omit<CustomField, 'id' | 'order'>>({
type: 'text',
label: '',
placeholder: '',
helpText: '',
required: false,
stepId: 'additional',
})
// Initialize local state from server data
useEffect(() => {
if (serverConfig && !initialized) {
setConfig(serverConfig)
setInitialized(true)
}
}, [serverConfig, initialized])
// --- DnD Sensors ---
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// --- Helper: update config and mark dirty ---
const updateLocalConfig = useCallback(
(updater: (prev: WizardConfig) => WizardConfig) => {
setConfig((prev) => updater(prev))
setIsDirty(true)
},
[]
)
// ============================================================================
// Tab 1: Steps handlers
// ============================================================================
function handleStepDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
updateLocalConfig((prev) => {
const steps = [...prev.steps]
const oldIndex = steps.findIndex((s) => s.id === active.id)
const newIndex = steps.findIndex((s) => s.id === over.id)
const reordered = arrayMove(steps, oldIndex, newIndex).map((s, i) => ({
...s,
order: i,
}))
return { ...prev, steps: reordered }
})
}
function handleStepToggle(id: WizardStepId, enabled: boolean) {
if (id === 'review') return
updateLocalConfig((prev) => ({
...prev,
steps: prev.steps.map((s) => (s.id === id ? { ...s, enabled } : s)),
}))
}
function handleStepTitleChange(id: WizardStepId, title: string) {
updateLocalConfig((prev) => ({
...prev,
steps: prev.steps.map((s) => (s.id === id ? { ...s, title } : s)),
}))
}
// ============================================================================
// Tab 2: Dropdown Options handlers
// ============================================================================
function getOptions(section: 'categories' | 'oceanIssues'): DropdownOption[] {
return section === 'categories'
? config.competitionCategories || []
: config.oceanIssues || []
}
function setOptions(
section: 'categories' | 'oceanIssues',
options: DropdownOption[]
) {
updateLocalConfig((prev) => ({
...prev,
...(section === 'categories'
? { competitionCategories: options }
: { oceanIssues: options }),
}))
}
function handleOptionDragEnd(
section: 'categories' | 'oceanIssues',
event: DragEndEvent
) {
const { active, over } = event
if (!over || active.id === over.id) return
const options = getOptions(section)
const oldIndex = options.findIndex(
(_, i) => `${section}-${i}` === active.id
)
const newIndex = options.findIndex(
(_, i) => `${section}-${i}` === over.id
)
if (oldIndex === -1 || newIndex === -1) return
setOptions(section, arrayMove(options, oldIndex, newIndex))
}
function openAddOptionDialog(section: 'categories' | 'oceanIssues') {
setOptionDialogSection(section)
setEditingOptionIndex(null)
setOptionForm({ value: '', label: '', description: '', icon: '' })
setOptionDialogOpen(true)
}
function openEditOptionDialog(
section: 'categories' | 'oceanIssues',
index: number
) {
const options = getOptions(section)
const option = options[index]
setOptionDialogSection(section)
setEditingOptionIndex(index)
setOptionForm({
value: option.value,
label: option.label,
description: option.description || '',
icon: option.icon || '',
})
setOptionDialogOpen(true)
}
function handleSaveOption() {
if (!optionForm.value.trim() || !optionForm.label.trim()) {
toast.error('Value and Label are required')
return
}
const options = getOptions(optionDialogSection)
// Clean up optional fields
const cleanOption: DropdownOption = {
value: optionForm.value.trim(),
label: optionForm.label.trim(),
...(optionForm.description?.trim()
? { description: optionForm.description.trim() }
: {}),
...(optionForm.icon?.trim() ? { icon: optionForm.icon.trim() } : {}),
}
if (editingOptionIndex !== null) {
const updated = [...options]
updated[editingOptionIndex] = cleanOption
setOptions(optionDialogSection, updated)
} else {
setOptions(optionDialogSection, [...options, cleanOption])
}
setOptionDialogOpen(false)
}
function handleDeleteOption(
section: 'categories' | 'oceanIssues',
index: number
) {
const options = getOptions(section)
if (options.length <= 1) {
toast.error('At least one option is required')
return
}
const option = options[index]
if (section === 'oceanIssues' && option.value === 'OTHER') {
toast.error('The "OTHER" option cannot be deleted')
return
}
setOptions(
section,
options.filter((_, i) => i !== index)
)
}
function canDeleteOption(
section: 'categories' | 'oceanIssues',
option: DropdownOption,
totalCount: number
): boolean {
if (totalCount <= 1) return false
if (section === 'oceanIssues' && option.value === 'OTHER') return false
return true
}
// ============================================================================
// Tab 3: Features handlers
// ============================================================================
function handleFeatureToggle(
key: keyof NonNullable<WizardConfig['features']>,
value: boolean
) {
updateLocalConfig((prev) => ({
...prev,
features: {
...prev.features,
[key]: value,
},
}))
}
// ============================================================================
// Tab 4: Welcome handlers
// ============================================================================
function handleWelcomeChange(
field: 'title' | 'description',
value: string
) {
updateLocalConfig((prev) => ({
...prev,
welcomeMessage: {
...prev.welcomeMessage,
[field]: value || undefined,
},
}))
}
// ============================================================================
// Tab 5: Custom Fields handlers
// ============================================================================
function openAddFieldDialog() {
setFieldForm({
type: 'text',
label: '',
placeholder: '',
helpText: '',
required: false,
stepId: 'additional',
options: [],
})
setFieldDialogOpen(true)
}
function handleSaveField() {
if (!fieldForm.label.trim()) {
toast.error('Field label is required')
return
}
const needsOptions = fieldForm.type === 'select' || fieldForm.type === 'multiselect'
if (needsOptions && (!fieldForm.options || fieldForm.options.length < 2)) {
toast.error('Select fields require at least 2 options')
return
}
const newField: CustomField = {
id: `custom_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
type: fieldForm.type,
label: fieldForm.label.trim(),
placeholder: fieldForm.placeholder?.trim() || undefined,
helpText: fieldForm.helpText?.trim() || undefined,
required: fieldForm.required,
stepId: fieldForm.stepId,
order: (config.customFields || []).length,
options: needsOptions ? fieldForm.options?.filter(Boolean) : undefined,
}
updateLocalConfig((prev) => ({
...prev,
customFields: [...(prev.customFields || []), newField],
}))
setFieldDialogOpen(false)
}
function handleDeleteField(fieldId: string) {
updateLocalConfig((prev) => ({
...prev,
customFields: (prev.customFields || []).filter((f) => f.id !== fieldId),
}))
}
// ============================================================================
// Save & Reset
// ============================================================================
function handleSave() {
updateConfig.mutate({ programId, wizardConfig: config })
}
function handleReset() {
setConfig(DEFAULT_WIZARD_CONFIG)
setIsDirty(true)
}
// ============================================================================
// Loading State
// ============================================================================
if (programLoading || configLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-12 w-full" />
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
)
}
// ============================================================================
// Grouped custom fields for Tab 5
// ============================================================================
const customFieldsByStep = (config.customFields || []).reduce(
(acc, field) => {
const stepId = field.stepId || 'additional'
if (!acc[stepId]) acc[stepId] = []
acc[stepId].push(field)
return acc
},
{} as Record<string, CustomField[]>
)
// ============================================================================
// Render
// ============================================================================
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Link href="/admin/programs" className="hover:text-foreground transition-colors">
Editions
</Link>
<span>/</span>
<Link
href={`/admin/programs/${programId}`}
className="hover:text-foreground transition-colors"
>
{program?.name} {program?.year}
</Link>
<span>/</span>
<span className="text-foreground">Apply Settings</span>
</div>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
Apply Wizard Settings
</h1>
{isDirty && (
<Badge variant="outline" className="text-amber-600 border-amber-300 bg-amber-50">
Unsaved changes
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1">
Customize the application wizard for {program?.name} {program?.year}
</p>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{/* Template controls */}
<Select
onValueChange={(value) => {
if (value === '__mopc_classic__') {
setConfig(DEFAULT_WIZARD_CONFIG)
setIsDirty(true)
toast.success('Loaded preset: MOPC Classic')
return
}
const template = templates?.find((t: { id: string; name: string; config: unknown }) => t.id === value)
if (template) {
setConfig(template.config as WizardConfig)
setIsDirty(true)
toast.success(`Loaded template: ${template.name}`)
}
}}
>
<SelectTrigger className="w-[200px]">
<Download className="mr-2 h-4 w-4" />
<SelectValue placeholder="Load template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__mopc_classic__">
MOPC Classic (Default)
</SelectItem>
{templates && templates.length > 0 && (
<>
{templates.map((t: { id: string; name: string }) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => setSaveTemplateOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Save as Template
</Button>
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset to Defaults
</Button>
<Button onClick={handleSave} disabled={updateConfig.isPending || !isDirty}>
{updateConfig.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
<Separator />
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="steps">Steps</TabsTrigger>
<TabsTrigger value="dropdowns">Dropdown Options</TabsTrigger>
<TabsTrigger value="features">Features</TabsTrigger>
<TabsTrigger value="welcome">Welcome</TabsTrigger>
<TabsTrigger value="fields">Custom Fields</TabsTrigger>
</TabsList>
{/* ================================================================ */}
{/* Tab 1: Steps */}
{/* ================================================================ */}
<TabsContent value="steps">
<Card>
<CardHeader>
<CardTitle>Wizard Steps</CardTitle>
<CardDescription>
Configure and reorder the application wizard steps. Drag to
reorder, toggle visibility, and edit titles. The
&quot;review&quot; step cannot be disabled.
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={config.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{config.steps
.sort((a, b) => a.order - b.order)
.map((step) => (
<SortableStepRow
key={step.id}
step={step}
onToggle={handleStepToggle}
onTitleChange={handleStepTitleChange}
/>
))}
</div>
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 2: Dropdown Options */}
{/* ================================================================ */}
<TabsContent value="dropdowns">
<div className="space-y-6">
{/* Competition Categories */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Competition Categories</CardTitle>
<CardDescription>
Categories applicants can select for their projects
</CardDescription>
</div>
<Button
size="sm"
onClick={() => openAddOptionDialog('categories')}
>
<Plus className="mr-2 h-4 w-4" />
Add
</Button>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleOptionDragEnd('categories', e)}
>
<SortableContext
items={(config.competitionCategories || []).map(
(_, i) => `categories-${i}`
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{(config.competitionCategories || []).map(
(option, index) => (
<SortableOptionRow
key={`categories-${index}`}
option={{
...option,
_id: `categories-${index}`,
}}
onEdit={() =>
openEditOptionDialog('categories', index)
}
onDelete={() =>
handleDeleteOption('categories', index)
}
canDelete={canDeleteOption(
'categories',
option,
(config.competitionCategories || []).length
)}
/>
)
)}
</div>
</SortableContext>
</DndContext>
{(config.competitionCategories || []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No categories defined. Add at least one.
</p>
)}
</CardContent>
</Card>
{/* Ocean Issues */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Ocean Issues</CardTitle>
<CardDescription>
Ocean-related issues applicants can select. The
&quot;OTHER&quot; option cannot be deleted.
</CardDescription>
</div>
<Button
size="sm"
onClick={() => openAddOptionDialog('oceanIssues')}
>
<Plus className="mr-2 h-4 w-4" />
Add
</Button>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleOptionDragEnd('oceanIssues', e)}
>
<SortableContext
items={(config.oceanIssues || []).map(
(_, i) => `oceanIssues-${i}`
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{(config.oceanIssues || []).map((option, index) => (
<SortableOptionRow
key={`oceanIssues-${index}`}
option={{
...option,
_id: `oceanIssues-${index}`,
}}
onEdit={() =>
openEditOptionDialog('oceanIssues', index)
}
onDelete={() =>
handleDeleteOption('oceanIssues', index)
}
canDelete={canDeleteOption(
'oceanIssues',
option,
(config.oceanIssues || []).length
)}
/>
))}
</div>
</SortableContext>
</DndContext>
{(config.oceanIssues || []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No ocean issues defined. Add at least one.
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* ================================================================ */}
{/* Tab 3: Features */}
{/* ================================================================ */}
<TabsContent value="features">
<Card>
<CardHeader>
<CardTitle>Feature Toggles</CardTitle>
<CardDescription>
Enable or disable optional features in the application wizard
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Team Members</Label>
<p className="text-sm text-muted-foreground">
Allow applicants to add team members to their project
</p>
</div>
<Switch
checked={config.features?.enableTeamMembers ?? true}
onCheckedChange={(v) =>
handleFeatureToggle('enableTeamMembers', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
Mentorship Requests
</Label>
<p className="text-sm text-muted-foreground">
Allow applicants to request a mentor during the application
</p>
</div>
<Switch
checked={config.features?.enableMentorship ?? true}
onCheckedChange={(v) =>
handleFeatureToggle('enableMentorship', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
WhatsApp Contact
</Label>
<p className="text-sm text-muted-foreground">
Collect WhatsApp contact information from applicants
</p>
</div>
<Switch
checked={config.features?.enableWhatsApp ?? false}
onCheckedChange={(v) =>
handleFeatureToggle('enableWhatsApp', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
Require Institution
</Label>
<p className="text-sm text-muted-foreground">
Require applicants to provide their institution or
organization
</p>
</div>
<Switch
checked={config.features?.requireInstitution ?? false}
onCheckedChange={(v) =>
handleFeatureToggle('requireInstitution', v)
}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 4: Welcome */}
{/* ================================================================ */}
<TabsContent value="welcome">
<Card>
<CardHeader>
<CardTitle>Welcome Message</CardTitle>
<CardDescription>
Customize the welcome screen shown at the start of the
application wizard
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="welcome-title">Title</Label>
<Input
id="welcome-title"
value={config.welcomeMessage?.title || ''}
onChange={(e) => handleWelcomeChange('title', e.target.value)}
placeholder="Welcome to the application process"
maxLength={200}
/>
<p className="text-xs text-muted-foreground">
Optional. Max 200 characters.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="welcome-description">Description</Label>
<Textarea
id="welcome-description"
value={config.welcomeMessage?.description || ''}
onChange={(e) =>
handleWelcomeChange('description', e.target.value)
}
placeholder="Please fill out the following form to submit your project for consideration..."
rows={4}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
Optional. Max 1000 characters.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 5: Custom Fields */}
{/* ================================================================ */}
<TabsContent value="fields">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Custom Fields</CardTitle>
<CardDescription>
Add custom fields to specific wizard steps
</CardDescription>
</div>
<Button size="sm" onClick={openAddFieldDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</CardHeader>
<CardContent>
{(config.customFields || []).length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p>No custom fields defined yet.</p>
<p className="text-sm mt-1">
Add fields to collect additional information from applicants.
</p>
</div>
) : (
<div className="space-y-6">
{WIZARD_STEP_IDS.filter(
(stepId) => customFieldsByStep[stepId]
).map((stepId) => (
<div key={stepId}>
<h4 className="text-sm font-semibold mb-2 capitalize">
Step: {config.steps.find((s) => s.id === stepId)?.title || stepId}
</h4>
<div className="space-y-2">
{customFieldsByStep[stepId].map((field) => (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border bg-card p-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{field.label}
</p>
<Badge variant="outline" className="text-xs">
{field.type}
</Badge>
{field.required && (
<Badge
variant="secondary"
className="text-xs"
>
Required
</Badge>
)}
</div>
{field.helpText && (
<p className="text-xs text-muted-foreground mt-0.5">
{field.helpText}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteField(field.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* ================================================================ */}
{/* Option Add/Edit Dialog */}
{/* ================================================================ */}
<Dialog open={optionDialogOpen} onOpenChange={setOptionDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingOptionIndex !== null ? 'Edit Option' : 'Add Option'}
</DialogTitle>
<DialogDescription>
{optionDialogSection === 'categories'
? 'Configure a competition category option'
: 'Configure an ocean issue option'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="option-value">Value *</Label>
<Input
id="option-value"
value={optionForm.value}
onChange={(e) =>
setOptionForm((prev) => ({
...prev,
value: e.target.value.toUpperCase().replace(/\s+/g, '_'),
}))
}
placeholder="e.g., MARINE_TECH"
disabled={editingOptionIndex !== null}
/>
<p className="text-xs text-muted-foreground">
Unique identifier (auto-formatted to SCREAMING_SNAKE_CASE).
Cannot be changed after creation.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="option-label">Label *</Label>
<Input
id="option-label"
value={optionForm.label}
onChange={(e) =>
setOptionForm((prev) => ({ ...prev, label: e.target.value }))
}
placeholder="e.g., Marine Technology"
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="option-description">Description</Label>
<Textarea
id="option-description"
value={optionForm.description || ''}
onChange={(e) =>
setOptionForm((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Optional description for this option"
rows={2}
maxLength={300}
/>
</div>
<div className="space-y-2">
<Label htmlFor="option-icon">Icon</Label>
<Input
id="option-icon"
value={optionForm.icon || ''}
onChange={(e) =>
setOptionForm((prev) => ({ ...prev, icon: e.target.value }))
}
placeholder="e.g., Rocket (Lucide icon name)"
/>
<p className="text-xs text-muted-foreground">
Optional Lucide icon name to display alongside this option.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOptionDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSaveOption}
disabled={!optionForm.value.trim() || !optionForm.label.trim()}
>
{editingOptionIndex !== null ? 'Update' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ================================================================ */}
{/* Save Template Dialog */}
{/* ================================================================ */}
<Dialog open={saveTemplateOpen} onOpenChange={setSaveTemplateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
<DialogDescription>
Save the current wizard configuration as a reusable template.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="template-name">Template Name *</Label>
<Input
id="template-name"
value={saveTemplateName}
onChange={(e) => setSaveTemplateName(e.target.value)}
placeholder="e.g., MOPC 2026 Standard"
maxLength={100}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSaveTemplateOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => {
createTemplate.mutate({
name: saveTemplateName,
config,
programId,
isGlobal: false,
})
}}
disabled={!saveTemplateName.trim() || createTemplate.isPending}
>
{createTemplate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ================================================================ */}
{/* Custom Field Dialog */}
{/* ================================================================ */}
<Dialog open={fieldDialogOpen} onOpenChange={setFieldDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Custom Field</DialogTitle>
<DialogDescription>
Define a new field to collect additional information from
applicants
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="field-type">Type *</Label>
<Select
value={fieldForm.type}
onValueChange={(v) => {
const isSelectType = v === 'select' || v === 'multiselect'
setFieldForm((prev) => ({
...prev,
type: v as CustomField['type'],
options: isSelectType ? (prev.options?.length ? prev.options : ['']) : [],
}))
}}
>
<SelectTrigger id="field-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="textarea">Textarea</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="multiselect">Multi-Select</SelectItem>
<SelectItem value="checkbox">Checkbox</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="field-label">Label *</Label>
<Input
id="field-label"
value={fieldForm.label}
onChange={(e) =>
setFieldForm((prev) => ({ ...prev, label: e.target.value }))
}
placeholder="e.g., Project Budget"
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="field-placeholder">Placeholder</Label>
<Input
id="field-placeholder"
value={fieldForm.placeholder || ''}
onChange={(e) =>
setFieldForm((prev) => ({
...prev,
placeholder: e.target.value,
}))
}
placeholder="e.g., Enter your project budget"
/>
</div>
<div className="space-y-2">
<Label htmlFor="field-help">Help Text</Label>
<Input
id="field-help"
value={fieldForm.helpText || ''}
onChange={(e) =>
setFieldForm((prev) => ({
...prev,
helpText: e.target.value,
}))
}
placeholder="e.g., Estimated budget in EUR"
/>
</div>
{/* Options editor for select/multiselect */}
{(fieldForm.type === 'select' || fieldForm.type === 'multiselect') && (
<div className="space-y-2">
<Label>Options *</Label>
<p className="text-xs text-muted-foreground">
Add at least 2 options. One per line.
</p>
<div className="space-y-2">
{(fieldForm.options || ['']).map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const updated = [...(fieldForm.options || [''])]
updated[idx] = e.target.value
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
placeholder={`Option ${idx + 1}`}
/>
{(fieldForm.options || []).length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
const updated = (fieldForm.options || []).filter((_, i) => i !== idx)
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setFieldForm((prev) => ({
...prev,
options: [...(prev.options || []), ''],
}))
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Option
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="field-step">Step *</Label>
<Select
value={fieldForm.stepId}
onValueChange={(v) =>
setFieldForm((prev) => ({
...prev,
stepId: v as WizardStepId,
}))
}
>
<SelectTrigger id="field-step">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.steps
.filter((s) => s.enabled)
.sort((a, b) => a.order - b.order)
.map((step) => (
<SelectItem key={step.id} value={step.id}>
{step.title || step.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Required</Label>
<p className="text-sm text-muted-foreground">
Applicants must fill out this field
</p>
</div>
<Switch
checked={fieldForm.required}
onCheckedChange={(v) =>
setFieldForm((prev) => ({ ...prev, required: v }))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setFieldDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSaveField}
disabled={!fieldForm.label.trim()}
>
Add Field
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}