1089 lines
42 KiB
JavaScript
1089 lines
42 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Card } from '../../components/ui/card';
|
|
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 { Checkbox } from '../../components/ui/checkbox';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../../components/ui/select';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '../../components/ui/dialog';
|
|
import { toast } from 'sonner';
|
|
import api from '../../utils/api';
|
|
import {
|
|
Loader2,
|
|
Save,
|
|
RotateCcw,
|
|
Eye,
|
|
Plus,
|
|
Trash2,
|
|
GripVertical,
|
|
Type,
|
|
Mail,
|
|
Phone,
|
|
Calendar,
|
|
List,
|
|
CheckSquare,
|
|
Circle,
|
|
AlignLeft,
|
|
Upload,
|
|
Lock,
|
|
Settings,
|
|
Zap,
|
|
X
|
|
} from 'lucide-react';
|
|
|
|
// Field type icons
|
|
const FIELD_TYPE_ICONS = {
|
|
text: Type,
|
|
email: Mail,
|
|
phone: Phone,
|
|
date: Calendar,
|
|
dropdown: List,
|
|
checkbox: CheckSquare,
|
|
radio: Circle,
|
|
multiselect: CheckSquare,
|
|
textarea: AlignLeft,
|
|
file_upload: Upload,
|
|
password: Lock,
|
|
address_group: Type,
|
|
};
|
|
|
|
// Field type labels
|
|
const FIELD_TYPE_LABELS = {
|
|
text: 'Text Input',
|
|
email: 'Email',
|
|
phone: 'Phone',
|
|
date: 'Date',
|
|
dropdown: 'Dropdown',
|
|
checkbox: 'Checkbox',
|
|
radio: 'Radio Group',
|
|
multiselect: 'Multi-Select',
|
|
textarea: 'Text Area',
|
|
file_upload: 'File Upload',
|
|
password: 'Password',
|
|
address_group: 'Address Group',
|
|
};
|
|
|
|
const AdminRegistrationBuilder = () => {
|
|
const [schema, setSchema] = useState(null);
|
|
const [originalSchema, setOriginalSchema] = useState(null);
|
|
const [fieldTypes, setFieldTypes] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
|
|
// UI State
|
|
const [selectedStep, setSelectedStep] = useState(null);
|
|
const [selectedSection, setSelectedSection] = useState(null);
|
|
const [selectedField, setSelectedField] = useState(null);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [addStepDialogOpen, setAddStepDialogOpen] = useState(false);
|
|
const [addSectionDialogOpen, setAddSectionDialogOpen] = useState(false);
|
|
const [addFieldDialogOpen, setAddFieldDialogOpen] = useState(false);
|
|
const [conditionalDialogOpen, setConditionalDialogOpen] = useState(false);
|
|
|
|
// Form state for dialogs
|
|
const [newStepData, setNewStepData] = useState({ title: '', description: '' });
|
|
const [newSectionData, setNewSectionData] = useState({ title: '', description: '' });
|
|
const [newFieldType, setNewFieldType] = useState('text');
|
|
|
|
// Fetch schema and field types on mount
|
|
useEffect(() => {
|
|
fetchSchema();
|
|
fetchFieldTypes();
|
|
}, []);
|
|
|
|
const fetchSchema = async () => {
|
|
try {
|
|
const response = await api.get('/admin/registration/schema');
|
|
setSchema(response.data.schema);
|
|
setOriginalSchema(JSON.parse(JSON.stringify(response.data.schema)));
|
|
if (response.data.schema?.steps?.length > 0) {
|
|
setSelectedStep(response.data.schema.steps[0].id);
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to load registration schema');
|
|
console.error(error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchFieldTypes = async () => {
|
|
try {
|
|
const response = await api.get('/admin/registration/field-types');
|
|
setFieldTypes(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to load field types:', error);
|
|
}
|
|
};
|
|
|
|
// Check for changes
|
|
useEffect(() => {
|
|
if (schema && originalSchema) {
|
|
setHasChanges(JSON.stringify(schema) !== JSON.stringify(originalSchema));
|
|
}
|
|
}, [schema, originalSchema]);
|
|
|
|
// Save schema
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
// Validate first
|
|
const validateResponse = await api.post('/admin/registration/schema/validate', {
|
|
schema_data: schema,
|
|
});
|
|
|
|
if (!validateResponse.data.valid) {
|
|
toast.error(validateResponse.data.errors.join(', '));
|
|
return;
|
|
}
|
|
|
|
// Save
|
|
await api.put('/admin/registration/schema', { schema_data: schema });
|
|
setOriginalSchema(JSON.parse(JSON.stringify(schema)));
|
|
toast.success('Registration form saved successfully');
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail?.message || 'Failed to save schema');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Reset to default
|
|
const handleReset = async () => {
|
|
if (!window.confirm('Are you sure you want to reset to the default registration form? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.post('/admin/registration/schema/reset');
|
|
await fetchSchema();
|
|
toast.success('Registration form reset to default');
|
|
} catch (error) {
|
|
toast.error('Failed to reset schema');
|
|
}
|
|
};
|
|
|
|
// Get sorted steps
|
|
const sortedSteps = schema?.steps?.sort((a, b) => a.order - b.order) || [];
|
|
|
|
// Get current step
|
|
const currentStep = sortedSteps.find((s) => s.id === selectedStep);
|
|
const currentStepIndex = sortedSteps.findIndex((s) => s.id === selectedStep);
|
|
|
|
// Get sections for current step
|
|
const sortedSections = currentStep?.sections?.sort((a, b) => a.order - b.order) || [];
|
|
|
|
// Update schema
|
|
const updateSchema = useCallback((updater) => {
|
|
setSchema((prev) => {
|
|
const updated = typeof updater === 'function' ? updater(prev) : updater;
|
|
return { ...updated };
|
|
});
|
|
}, []);
|
|
|
|
// Add step
|
|
const handleAddStep = () => {
|
|
if (!newStepData.title.trim()) {
|
|
toast.error('Step title is required');
|
|
return;
|
|
}
|
|
|
|
const newStep = {
|
|
id: `step_${Date.now()}`,
|
|
title: newStepData.title,
|
|
description: newStepData.description,
|
|
order: sortedSteps.length + 1,
|
|
sections: [],
|
|
};
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: [...prev.steps, newStep],
|
|
}));
|
|
|
|
setSelectedStep(newStep.id);
|
|
setAddStepDialogOpen(false);
|
|
setNewStepData({ title: '', description: '' });
|
|
};
|
|
|
|
// Delete step
|
|
const handleDeleteStep = (stepId) => {
|
|
const step = schema.steps.find((s) => s.id === stepId);
|
|
if (step?.sections?.some((sec) => sec.fields?.some((f) => f.is_fixed))) {
|
|
toast.error('Cannot delete step containing fixed fields');
|
|
return;
|
|
}
|
|
|
|
if (!window.confirm('Are you sure you want to delete this step?')) {
|
|
return;
|
|
}
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps
|
|
.filter((s) => s.id !== stepId)
|
|
.map((s, idx) => ({ ...s, order: idx + 1 })),
|
|
}));
|
|
|
|
if (selectedStep === stepId) {
|
|
setSelectedStep(sortedSteps[0]?.id);
|
|
}
|
|
};
|
|
|
|
// Move step
|
|
const handleMoveStep = (stepId, direction) => {
|
|
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
|
|
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
|
|
|
|
if (newIndex < 0 || newIndex >= sortedSteps.length) return;
|
|
|
|
updateSchema((prev) => {
|
|
const steps = [...prev.steps];
|
|
const step = steps.find((s) => s.id === stepId);
|
|
const otherStep = sortedSteps[newIndex];
|
|
const otherStepInArray = steps.find((s) => s.id === otherStep.id);
|
|
|
|
const tempOrder = step.order;
|
|
step.order = otherStepInArray.order;
|
|
otherStepInArray.order = tempOrder;
|
|
|
|
return { ...prev, steps };
|
|
});
|
|
};
|
|
|
|
// Add section
|
|
const handleAddSection = () => {
|
|
if (!newSectionData.title.trim()) {
|
|
toast.error('Section title is required');
|
|
return;
|
|
}
|
|
|
|
const newSection = {
|
|
id: `section_${Date.now()}`,
|
|
title: newSectionData.title,
|
|
description: newSectionData.description,
|
|
order: sortedSections.length + 1,
|
|
fields: [],
|
|
};
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? { ...s, sections: [...(s.sections || []), newSection] }
|
|
: s
|
|
),
|
|
}));
|
|
|
|
setSelectedSection(newSection.id);
|
|
setAddSectionDialogOpen(false);
|
|
setNewSectionData({ title: '', description: '' });
|
|
};
|
|
|
|
// Delete section
|
|
const handleDeleteSection = (sectionId) => {
|
|
const section = currentStep?.sections?.find((s) => s.id === sectionId);
|
|
if (section?.fields?.some((f) => f.is_fixed)) {
|
|
toast.error('Cannot delete section containing fixed fields');
|
|
return;
|
|
}
|
|
|
|
if (!window.confirm('Are you sure you want to delete this section?')) {
|
|
return;
|
|
}
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? {
|
|
...s,
|
|
sections: s.sections
|
|
.filter((sec) => sec.id !== sectionId)
|
|
.map((sec, idx) => ({ ...sec, order: idx + 1 })),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
|
|
if (selectedSection === sectionId) {
|
|
setSelectedSection(null);
|
|
}
|
|
};
|
|
|
|
// Add field
|
|
const handleAddField = (sectionId = selectedSection) => {
|
|
if (!sectionId) {
|
|
toast.error('Please select a section first');
|
|
return;
|
|
}
|
|
|
|
const section = currentStep?.sections?.find((s) => s.id === sectionId);
|
|
const fieldCount = section?.fields?.length || 0;
|
|
|
|
const newField = {
|
|
id: `field_${Date.now()}`,
|
|
type: newFieldType,
|
|
label: `New ${FIELD_TYPE_LABELS[newFieldType] || 'Field'}`,
|
|
required: false,
|
|
is_fixed: false,
|
|
width: 'full',
|
|
order: fieldCount + 1,
|
|
options: ['dropdown', 'radio', 'multiselect'].includes(newFieldType)
|
|
? [{ value: 'option1', label: 'Option 1' }]
|
|
: undefined,
|
|
};
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? {
|
|
...s,
|
|
sections: s.sections.map((sec) =>
|
|
sec.id === sectionId
|
|
? { ...sec, fields: [...(sec.fields || []), newField] }
|
|
: sec
|
|
),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
|
|
setSelectedSection(sectionId);
|
|
setSelectedField(newField.id);
|
|
setAddFieldDialogOpen(false);
|
|
};
|
|
|
|
// Delete field
|
|
const handleDeleteField = (sectionId, fieldId) => {
|
|
const section = currentStep?.sections?.find((s) => s.id === sectionId);
|
|
const field = section?.fields?.find((f) => f.id === fieldId);
|
|
|
|
if (field?.is_fixed) {
|
|
toast.error('Cannot delete fixed fields');
|
|
return;
|
|
}
|
|
|
|
if (!window.confirm('Are you sure you want to delete this field?')) {
|
|
return;
|
|
}
|
|
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? {
|
|
...s,
|
|
sections: s.sections.map((sec) =>
|
|
sec.id === sectionId
|
|
? {
|
|
...sec,
|
|
fields: sec.fields
|
|
.filter((f) => f.id !== fieldId)
|
|
.map((f, idx) => ({ ...f, order: idx + 1 })),
|
|
}
|
|
: sec
|
|
),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
|
|
if (selectedField === fieldId) {
|
|
setSelectedField(null);
|
|
}
|
|
};
|
|
|
|
// Update field
|
|
const handleUpdateField = (sectionId, fieldId, updates) => {
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? {
|
|
...s,
|
|
sections: s.sections.map((sec) =>
|
|
sec.id === sectionId
|
|
? {
|
|
...sec,
|
|
fields: sec.fields.map((f) =>
|
|
f.id === fieldId ? { ...f, ...updates } : f
|
|
),
|
|
}
|
|
: sec
|
|
),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
};
|
|
|
|
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Registration Form Builder
|
|
</h1>
|
|
<p className="text-lg text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Customize the registration form steps, sections, and fields.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
|
|
<Eye className="h-4 w-4 mr-2" />
|
|
Preview
|
|
</Button>
|
|
<Button variant="outline" onClick={handleReset}>
|
|
<RotateCcw className="h-4 w-4 mr-2" />
|
|
Reset
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving || !hasChanges}>
|
|
{saving ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4 mr-2" />
|
|
)}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{hasChanges && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm">
|
|
You have unsaved changes. Click "Save Changes" to apply them.
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Builder Layout */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
<div className="lg:col-span-9">
|
|
<Card className="p-4">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-lg font-semibold">Steps</h2>
|
|
<Button size="sm" variant="ghost" className="w-32" onClick={() => setAddStepDialogOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Step
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-end gap-2 border-b border-gray-200">
|
|
{sortedSteps.map((step, index) => (
|
|
<div
|
|
key={step.id}
|
|
className={`px-3 py-2 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id
|
|
? 'bg-white border-gray-300 border-b-white'
|
|
: 'bg-gray-50 border-transparent hover:bg-gray-100'
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedStep(step.id);
|
|
setSelectedSection(null);
|
|
setSelectedField(null);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">Step {index + 1}</span>
|
|
{/* <span className="text-sm text-muted-foreground">{step.title || 'Untitled Step'}</span> */}
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover:text-white"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteStep(step.id);
|
|
}}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{sortedSteps.length === 0 && (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
No steps yet. Click "Add Step" to get started.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sections for selected step */}
|
|
{currentStep && (
|
|
<div className="mt-6">
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
|
|
<div>
|
|
<h2 className="text-xl font-semibold">
|
|
Step {currentStepIndex + 1}: {currentStep?.title || 'Untitled Step'}
|
|
</h2>
|
|
{currentStep.description && (
|
|
<p className="text-sm text-muted-foreground mt-1">{currentStep.description}</p>
|
|
)}
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Add Section
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{sortedSections.map((section) => {
|
|
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
|
|
|
|
return (
|
|
<div
|
|
key={section.id}
|
|
className={`p-6 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id
|
|
? 'border-brand-purple bg-brand-lavender/10'
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedSection(section.id);
|
|
setSelectedField(null);
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-lg font-medium">{section.title || 'Untitled Section'}</h3>
|
|
{section.description && (
|
|
<p className="text-sm text-muted-foreground mt-1">{section.description}</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover:text-red-700"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteSection(section.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Fields */}
|
|
<div className="mt-4 space-y-3">
|
|
{sortedFields.map((field) => {
|
|
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
|
|
const options = field.options || [];
|
|
return (
|
|
<div
|
|
key={field.id}
|
|
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedField === field.id
|
|
? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20'
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
} ${field.is_fixed ? 'bg-gray-50' : ''}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedSection(section.id);
|
|
setSelectedField(field.id);
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
|
<IconComponent className="h-4 w-4 text-gray-500" />
|
|
<div>
|
|
<span className="font-medium text-sm">
|
|
{field.label}
|
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
({FIELD_TYPE_LABELS[field.type] || field.type})
|
|
</span>
|
|
</div>
|
|
{field.is_fixed && (
|
|
<Lock className="h-3 w-3 text-gray-400" title="Fixed field - cannot be removed" />
|
|
)}
|
|
</div>
|
|
{!field.is_fixed && (
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover:text-red-700"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteField(section.id, field.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{selectedField === field.id && (
|
|
<div className="mt-4 border-t pt-4 space-y-4">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Field ID</Label>
|
|
<div className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
|
{field.id}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor={`field-label-${field.id}`}>Label</Label>
|
|
<Input
|
|
id={`field-label-${field.id}`}
|
|
value={field.label}
|
|
onChange={(e) => handleUpdateField(section.id, field.id, { label: e.target.value })}
|
|
disabled={field.is_fixed}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Type</Label>
|
|
<Select
|
|
value={field.type}
|
|
onValueChange={(value) => handleUpdateField(section.id, field.id, { type: value })}
|
|
disabled={field.is_fixed}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(FIELD_TYPE_LABELS).map(([type, label]) => (
|
|
<SelectItem key={type} value={type}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`field-required-${field.id}`}
|
|
checked={field.required}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateField(section.id, field.id, { required: checked })
|
|
}
|
|
disabled={field.is_fixed}
|
|
/>
|
|
<Label htmlFor={`field-required-${field.id}`}>Required</Label>
|
|
</div>
|
|
<div>
|
|
<Label>Width</Label>
|
|
<Select
|
|
value={field.width || 'full'}
|
|
onValueChange={(value) => handleUpdateField(section.id, field.id, { width: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="full">Full Width</SelectItem>
|
|
<SelectItem value="half">Half Width</SelectItem>
|
|
<SelectItem value="third">Third Width</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{['text', 'email', 'phone', 'textarea'].includes(field.type) && (
|
|
<div>
|
|
<Label htmlFor={`field-placeholder-${field.id}`}>Placeholder</Label>
|
|
<Input
|
|
id={`field-placeholder-${field.id}`}
|
|
value={field.placeholder || ''}
|
|
onChange={(e) =>
|
|
handleUpdateField(section.id, field.id, { placeholder: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{['dropdown', 'radio', 'multiselect'].includes(field.type) && (
|
|
<div>
|
|
<Label>Options</Label>
|
|
<div className="space-y-2 mt-2">
|
|
{options.map((option, idx) => (
|
|
<div key={idx} className="flex items-center gap-2">
|
|
<Input
|
|
value={option.label}
|
|
onChange={(e) => {
|
|
const newOptions = [...options];
|
|
newOptions[idx] = {
|
|
...newOptions[idx],
|
|
label: e.target.value,
|
|
value: e.target.value.toLowerCase().replace(/\s+/g, '_'),
|
|
};
|
|
handleUpdateField(section.id, field.id, { options: newOptions });
|
|
}}
|
|
placeholder="Option label"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
onClick={() => {
|
|
const newOptions = options.filter((_, i) => i !== idx);
|
|
handleUpdateField(section.id, field.id, { options: newOptions });
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newOptions = [
|
|
...options,
|
|
{ value: `option_${Date.now()}`, label: 'New Option' },
|
|
];
|
|
handleUpdateField(section.id, field.id, { options: newOptions });
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Option
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor={`field-mapping-${field.id}`}>Database Mapping</Label>
|
|
<Input
|
|
id={`field-mapping-${field.id}`}
|
|
value={field.mapping || ''}
|
|
onChange={(e) =>
|
|
handleUpdateField(section.id, field.id, { mapping: e.target.value })
|
|
}
|
|
placeholder="Leave empty for custom data"
|
|
disabled={field.is_fixed}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Maps to User model field. Empty = stored in custom_registration_data
|
|
</p>
|
|
</div>
|
|
|
|
{field.is_fixed && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm">
|
|
This is a fixed field and cannot be removed or have its core properties changed.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{sortedFields.length === 0 && (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
No fields in this section. Click "Add Field" to add one.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
className="w-full mt-4"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedSection(section.id);
|
|
setSelectedField(null);
|
|
setAddFieldDialogOpen(true);
|
|
}}
|
|
>
|
|
<Plus className="size-4 mr-2" />
|
|
Add Field
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{sortedSections.length === 0 && (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
No sections yet. Click "Add Section" to get started.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="lg:col-span-3">
|
|
<Card className="p-6">
|
|
<div className="flex flex-col justify-between items-center mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-5 w-5 text-yellow-500" />
|
|
<h2 className="text-lg font-semibold">Conditional Rules</h2>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={() => setConditionalDialogOpen(true)}>
|
|
<Settings className="h-4 w-4 mr-2" />
|
|
Manage Rules
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
{schema?.conditional_rules?.length || 0} conditional rule(s) configured
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Step Dialog */}
|
|
|
|
<Dialog open={addStepDialogOpen} onOpenChange={setAddStepDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add New Step</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label htmlFor="step-title">Step Title</Label>
|
|
<Input
|
|
id="step-title"
|
|
value={newStepData.title}
|
|
onChange={(e) => setNewStepData({ ...newStepData, title: e.target.value })}
|
|
placeholder="e.g., Additional Information"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="step-description">Description (optional)</Label>
|
|
<Textarea
|
|
id="step-description"
|
|
value={newStepData.description}
|
|
onChange={(e) => setNewStepData({ ...newStepData, description: e.target.value })}
|
|
placeholder="Explain what this step is about"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddStepDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddStep}>Add Step</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Section Dialog */}
|
|
<Dialog open={addSectionDialogOpen} onOpenChange={setAddSectionDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add New Section</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label htmlFor="section-title">Section Title</Label>
|
|
<Input
|
|
id="section-title"
|
|
value={newSectionData.title}
|
|
onChange={(e) => setNewSectionData({ ...newSectionData, title: e.target.value })}
|
|
placeholder="e.g., Contact Information"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="section-description">Description (optional)</Label>
|
|
<Textarea
|
|
id="section-description"
|
|
value={newSectionData.description}
|
|
onChange={(e) =>
|
|
setNewSectionData({ ...newSectionData, description: e.target.value })
|
|
}
|
|
placeholder="Explain what this section is for"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddSectionDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddSection}>Add Section</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Field Dialog */}
|
|
<Dialog open={addFieldDialogOpen} onOpenChange={setAddFieldDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add New Field</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label>Field Type</Label>
|
|
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(FIELD_TYPE_LABELS).map(([type, label]) => (
|
|
<SelectItem key={type} value={type}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddFieldDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddField}>Add Field</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Conditional Rules Dialog */}
|
|
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Conditional Rules</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
|
|
{(schema?.conditional_rules || []).map((rule, index) => (
|
|
<div key={rule.id} className="p-4 border rounded-lg space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-medium">Rule {index + 1}</span>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover-text-background"
|
|
onClick={() => {
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
|
}));
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '}
|
|
{rule.trigger_operator}{' '}
|
|
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '}
|
|
{rule.action} fields:{' '}
|
|
<span className="font-mono bg-gray-100 px-1">
|
|
{rule.target_fields?.join(', ')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{(schema?.conditional_rules || []).length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No conditional rules configured. Rules allow you to show or hide fields based on
|
|
other field values.
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => {
|
|
const newRule = {
|
|
id: `rule_${Date.now()}`,
|
|
trigger_field: '',
|
|
trigger_operator: 'equals',
|
|
trigger_value: true,
|
|
action: 'show',
|
|
target_fields: [],
|
|
};
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
conditional_rules: [...(prev.conditional_rules || []), newRule],
|
|
}));
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Rule
|
|
</Button>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => setConditionalDialogOpen(false)}>Done</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Preview Dialog */}
|
|
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Form Preview</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<p className="text-muted-foreground mb-4">
|
|
This is a preview of how the registration form will appear to users.
|
|
</p>
|
|
{/* Simplified preview */}
|
|
<div className="space-y-6">
|
|
{sortedSteps.map((step) => (
|
|
<div key={step.id} className="border rounded-lg p-4">
|
|
<h3 className="text-lg font-semibold mb-2">{step.title}</h3>
|
|
{step.description && (
|
|
<p className="text-sm text-muted-foreground mb-4">{step.description}</p>
|
|
)}
|
|
{step.sections
|
|
?.sort((a, b) => a.order - b.order)
|
|
.map((section) => (
|
|
<div key={section.id} className="mb-4">
|
|
<h4 className="font-medium mb-2">{section.title}</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{section.fields
|
|
?.sort((a, b) => a.order - b.order)
|
|
.map((field) => (
|
|
<div
|
|
key={field.id}
|
|
className={`${field.width === 'full'
|
|
? 'col-span-2'
|
|
: field.width === 'third'
|
|
? 'col-span-1'
|
|
: 'col-span-1'
|
|
}`}
|
|
>
|
|
<Label>
|
|
{field.label}
|
|
{field.required && (
|
|
<span className="text-red-500 ml-1">*</span>
|
|
)}
|
|
</Label>
|
|
<Input disabled placeholder={field.placeholder || field.label} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => setPreviewOpen(false)}>Close</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminRegistrationBuilder;
|