1474 lines
49 KiB
TypeScript
1474 lines
49 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) => 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) => (
|
||
|
|
<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>
|
||
|
|
)
|
||
|
|
}
|