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>
2026-02-08 13:18:20 +01:00
|
|
|
'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
|
|
|
|
|
}
|
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
|
|
|
const template = templates?.find((t: { id: string; name: string; config: unknown }) => t.id === value)
|
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>
2026-02-08 13:18:20 +01:00
|
|
|
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 && (
|
|
|
|
|
<>
|
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
|
|
|
{templates.map((t: { id: string; name: string }) => (
|
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>
2026-02-08 13:18:20 +01:00
|
|
|
<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
|
|
|
|
|
"review" 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
|
|
|
|
|
"OTHER" 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>
|
|
|
|
|
)
|
|
|
|
|
}
|