1. Trigger Field Selection - Dropdown to select which field triggers the rule (filters to checkbox, dropdown, radio, text fields) 2. Operator Selection - Dropdown with options: - equals - not equals - contains - is not empty - is empty 3. Value Input - Smart input based on field type: - Checkbox fields → dropdown with "Checked (true)" / "Unchecked (false)" - empty/not_empty operators → disabled (no value needed) - Other fields → text input 4. Action Selection - Dropdown with options: - Show fields - Hide fields - Make required - Make optional 5. Target Fields - Checkbox list of all fields (excluding the trigger field) to select which fields are affected 6. Rule Summary - A blue info box at the bottom of each rule showing a human-readable summary of the configured rule
1329 lines
48 KiB
JavaScript
1329 lines
48 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 {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
} from '../../components/ui/accordion';
|
|
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,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
Settings,
|
|
Zap,
|
|
Copy,
|
|
X,
|
|
Grip
|
|
} 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);
|
|
|
|
// 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 = () => {
|
|
if (!selectedSection) {
|
|
toast.error('Please select a section first');
|
|
return;
|
|
}
|
|
|
|
const section = currentStep?.sections?.find((s) => s.id === selectedSection);
|
|
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 === selectedSection
|
|
? { ...sec, fields: [...(sec.fields || []), newField] }
|
|
: sec
|
|
),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
|
|
setSelectedField(newField.id);
|
|
setAddFieldDialogOpen(false);
|
|
};
|
|
|
|
// Delete field
|
|
const handleDeleteField = (fieldId) => {
|
|
const section = currentStep?.sections?.find((s) => s.id === selectedSection);
|
|
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 === selectedSection
|
|
? {
|
|
...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 = (fieldId, updates) => {
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
steps: prev.steps.map((s) =>
|
|
s.id === selectedStep
|
|
? {
|
|
...s,
|
|
sections: s.sections.map((sec) =>
|
|
sec.id === selectedSection
|
|
? {
|
|
...sec,
|
|
fields: sec.fields.map((f) =>
|
|
f.id === fieldId ? { ...f, ...updates } : f
|
|
),
|
|
}
|
|
: sec
|
|
),
|
|
}
|
|
: s
|
|
),
|
|
}));
|
|
};
|
|
|
|
// Get selected field data
|
|
const selectedFieldData = currentStep?.sections
|
|
?.find((s) => s.id === selectedSection)
|
|
?.fields?.find((f) => f.id === selectedField);
|
|
|
|
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 */}
|
|
{/* Left Sidebar - Steps & Sections */}
|
|
<div className="lg:col-span-3">
|
|
<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" onClick={() => setAddStepDialogOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex space-y-2">
|
|
{sortedSteps.map((step, index) => (
|
|
<div
|
|
key={step.id}
|
|
className={`p-3 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id
|
|
? ' bg-brand-lavender/10 border-b-4 border-b-brand-dark-lavender'
|
|
: ''
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedStep(step.id);
|
|
setSelectedSection(null);
|
|
setSelectedField(null);
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="font-medium">Step: </div>
|
|
<span className="font-medium text-sm">{step.title}</span>
|
|
</div>
|
|
{/* Mod Buttons */}
|
|
<div className="flex items-center gap-1">
|
|
{/* <Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleMoveStep(step.id, 'up');
|
|
}}
|
|
disabled={index === 0}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleMoveStep(step.id, 'down');
|
|
}}
|
|
disabled={index === sortedSteps.length - 1}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button> */}
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover:text-white ml-2"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteStep(step.id);
|
|
}}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sections for selected step */}
|
|
{currentStep && (
|
|
<>
|
|
<div className="flex justify-between items-center mt-6 mb-4">
|
|
<h2 className="text-lg font-semibold">Sections</h2>
|
|
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{sortedSections.map((section) => (
|
|
<div
|
|
key={section.id}
|
|
className={`p-3 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-center justify-between">
|
|
<span className="text-sm">{section.title}</span>
|
|
<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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
|
|
{/* Center - Form Canvas */}
|
|
<div className="lg:col-span-9">
|
|
|
|
<Card className="p-6">
|
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div className='relative -mx-11 -my-2 flex gap-2 items-center'>
|
|
<Grip className="size-10 text-gray-400 py-2 bg-background" />
|
|
|
|
<h2 className="text-xl font-semibold">
|
|
{currentStep?.title || 'Select a step'}
|
|
</h2>
|
|
</div>
|
|
{selectedSection && (
|
|
<Button size="sm" onClick={() => setAddFieldDialogOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Field
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{currentStep?.description && (
|
|
<p className="text-muted-foreground mb-6">{currentStep.description}</p>
|
|
)}
|
|
|
|
{/* Sections and Fields */}
|
|
{sortedSections.map((section) => {
|
|
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
|
|
|
|
return (
|
|
<div
|
|
key={section.id}
|
|
className={`mb-6 p-4 rounded-lg border-2 ${selectedSection === section.id
|
|
? 'border-brand-purple'
|
|
: 'border-dashed border-gray-200'
|
|
}`}
|
|
onClick={() => setSelectedSection(section.id)}
|
|
>
|
|
<h3 className="text-lg font-medium mb-4">{section.title}</h3>
|
|
{section.description && (
|
|
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
|
|
)}
|
|
|
|
{/* Fields */}
|
|
<div className="space-y-3">
|
|
{sortedFields.map((field) => {
|
|
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
|
|
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(field.id);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{sortedFields.length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No fields in this section. Click "Add Field" to add one.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{sortedSections.length === 0 && (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
No sections in this step. Add a section from the left sidebar.
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
{/* Right Sidebar - Field Properties */}
|
|
<div className="lg:col-span-3">
|
|
<Card className="p-4">
|
|
<h2 className="text-lg font-semibold mb-4">Field Properties</h2>
|
|
|
|
{selectedFieldData ? (
|
|
<div className="space-y-4">
|
|
{/* Field ID */}
|
|
<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">
|
|
{selectedFieldData.id}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Label */}
|
|
<div>
|
|
<Label htmlFor="field-label">Label</Label>
|
|
<Input
|
|
id="field-label"
|
|
value={selectedFieldData.label}
|
|
onChange={(e) => handleUpdateField(selectedField, { label: e.target.value })}
|
|
disabled={selectedFieldData.is_fixed}
|
|
/>
|
|
</div>
|
|
|
|
{/* Type */}
|
|
<div>
|
|
<Label>Type</Label>
|
|
<Select
|
|
value={selectedFieldData.type}
|
|
onValueChange={(value) => handleUpdateField(selectedField, { type: value })}
|
|
disabled={selectedFieldData.is_fixed}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(FIELD_TYPE_LABELS).map(([type, label]) => (
|
|
<SelectItem key={type} value={type}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Required */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="field-required"
|
|
checked={selectedFieldData.required}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateField(selectedField, { required: checked })
|
|
}
|
|
disabled={selectedFieldData.is_fixed}
|
|
/>
|
|
<Label htmlFor="field-required">Required</Label>
|
|
</div>
|
|
|
|
{/* Width */}
|
|
<div>
|
|
<Label>Width</Label>
|
|
<Select
|
|
value={selectedFieldData.width || 'full'}
|
|
onValueChange={(value) => handleUpdateField(selectedField, { 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>
|
|
|
|
{/* Placeholder */}
|
|
{['text', 'email', 'phone', 'textarea'].includes(selectedFieldData.type) && (
|
|
<div>
|
|
<Label htmlFor="field-placeholder">Placeholder</Label>
|
|
<Input
|
|
id="field-placeholder"
|
|
value={selectedFieldData.placeholder || ''}
|
|
onChange={(e) =>
|
|
handleUpdateField(selectedField, { placeholder: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Options for dropdown/radio/multiselect */}
|
|
{['dropdown', 'radio', 'multiselect'].includes(selectedFieldData.type) && (
|
|
<div>
|
|
<Label>Options</Label>
|
|
<div className="space-y-2 mt-2">
|
|
{(selectedFieldData.options || []).map((option, idx) => (
|
|
<div key={idx} className="flex items-center gap-2">
|
|
<Input
|
|
value={option.label}
|
|
onChange={(e) => {
|
|
const newOptions = [...selectedFieldData.options];
|
|
newOptions[idx] = {
|
|
...newOptions[idx],
|
|
label: e.target.value,
|
|
value: e.target.value.toLowerCase().replace(/\s+/g, '_'),
|
|
};
|
|
handleUpdateField(selectedField, { options: newOptions });
|
|
}}
|
|
placeholder="Option label"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-8 w-8"
|
|
onClick={() => {
|
|
const newOptions = selectedFieldData.options.filter(
|
|
(_, i) => i !== idx
|
|
);
|
|
handleUpdateField(selectedField, { options: newOptions });
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newOptions = [
|
|
...(selectedFieldData.options || []),
|
|
{ value: `option_${Date.now()}`, label: 'New Option' },
|
|
];
|
|
handleUpdateField(selectedField, { options: newOptions });
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Option
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mapping */}
|
|
<div>
|
|
<Label htmlFor="field-mapping">Database Mapping</Label>
|
|
<Input
|
|
id="field-mapping"
|
|
value={selectedFieldData.mapping || ''}
|
|
onChange={(e) =>
|
|
handleUpdateField(selectedField, { mapping: e.target.value })
|
|
}
|
|
placeholder="Leave empty for custom data"
|
|
disabled={selectedFieldData.is_fixed}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Maps to User model field. Empty = stored in custom_registration_data
|
|
</p>
|
|
</div>
|
|
|
|
{selectedFieldData.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 className="text-center py-8 text-muted-foreground">
|
|
Select a field to edit its properties
|
|
</div>
|
|
)}
|
|
</Card>
|
|
{/* Conditional Rules */}
|
|
<Card className="p-6 mt-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-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Conditional Rules</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
|
<p className="text-sm text-muted-foreground">
|
|
Rules allow you to show or hide fields based on other field values. For example, show a "Scholarship Reason" field only when "Request Scholarship" is checked.
|
|
</p>
|
|
|
|
{(schema?.conditional_rules || []).map((rule, index) => {
|
|
// Get all available fields for dropdowns
|
|
const allFields = schema?.steps?.flatMap(step =>
|
|
step.sections?.flatMap(section =>
|
|
section.fields?.map(field => ({
|
|
id: field.id,
|
|
label: field.label,
|
|
type: field.type,
|
|
stepTitle: step.title,
|
|
sectionTitle: section.title,
|
|
})) || []
|
|
) || []
|
|
) || [];
|
|
|
|
// Filter trigger fields (checkbox, dropdown, radio work best)
|
|
const triggerFields = allFields.filter(f =>
|
|
['checkbox', 'dropdown', 'radio', 'text'].includes(f.type)
|
|
);
|
|
|
|
// Update a specific rule
|
|
const updateRule = (ruleId, updates) => {
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
conditional_rules: prev.conditional_rules.map((r) =>
|
|
r.id === ruleId ? { ...r, ...updates } : r
|
|
),
|
|
}));
|
|
};
|
|
|
|
// Get the trigger field's type to determine value input
|
|
const triggerFieldData = allFields.find(f => f.id === rule.trigger_field);
|
|
|
|
return (
|
|
<div key={rule.id} className="p-4 border rounded-lg space-y-4 bg-gray-50">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-medium text-sm">Rule {index + 1}</span>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6 w-6 text-red-500 hover:text-red-700"
|
|
onClick={() => {
|
|
updateSchema((prev) => ({
|
|
...prev,
|
|
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
|
}));
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Trigger Field Selection */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div>
|
|
<Label className="text-xs">When this field...</Label>
|
|
<Select
|
|
value={rule.trigger_field || ''}
|
|
onValueChange={(value) => updateRule(rule.id, { trigger_field: value })}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="Select field" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{triggerFields.map((field) => (
|
|
<SelectItem key={field.id} value={field.id}>
|
|
{field.label}
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
({field.type})
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">Operator</Label>
|
|
<Select
|
|
value={rule.trigger_operator || 'equals'}
|
|
onValueChange={(value) => updateRule(rule.id, { trigger_operator: value })}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="equals">equals</SelectItem>
|
|
<SelectItem value="not_equals">not equals</SelectItem>
|
|
<SelectItem value="contains">contains</SelectItem>
|
|
<SelectItem value="not_empty">is not empty</SelectItem>
|
|
<SelectItem value="empty">is empty</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">Value</Label>
|
|
{triggerFieldData?.type === 'checkbox' ? (
|
|
<Select
|
|
value={String(rule.trigger_value)}
|
|
onValueChange={(value) =>
|
|
updateRule(rule.id, { trigger_value: value === 'true' })
|
|
}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">Checked (true)</SelectItem>
|
|
<SelectItem value="false">Unchecked (false)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : ['empty', 'not_empty'].includes(rule.trigger_operator) ? (
|
|
<Input
|
|
className="mt-1"
|
|
value="(no value needed)"
|
|
disabled
|
|
/>
|
|
) : (
|
|
<Input
|
|
className="mt-1"
|
|
value={rule.trigger_value || ''}
|
|
onChange={(e) => updateRule(rule.id, { trigger_value: e.target.value })}
|
|
placeholder="Enter value"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Selection */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs">Action</Label>
|
|
<Select
|
|
value={rule.action || 'show'}
|
|
onValueChange={(value) => updateRule(rule.id, { action: value })}
|
|
>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="show">Show fields</SelectItem>
|
|
<SelectItem value="hide">Hide fields</SelectItem>
|
|
<SelectItem value="require">Make required</SelectItem>
|
|
<SelectItem value="optional">Make optional</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">Target Fields</Label>
|
|
<div className="mt-1 border rounded-md p-2 bg-white max-h-32 overflow-y-auto">
|
|
{allFields.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No fields available</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{allFields
|
|
.filter(f => f.id !== rule.trigger_field) // Don't show trigger field as target
|
|
.map((field) => (
|
|
<div key={field.id} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`${rule.id}-${field.id}`}
|
|
checked={(rule.target_fields || []).includes(field.id)}
|
|
onCheckedChange={(checked) => {
|
|
const currentTargets = rule.target_fields || [];
|
|
const newTargets = checked
|
|
? [...currentTargets, field.id]
|
|
: currentTargets.filter((id) => id !== field.id);
|
|
updateRule(rule.id, { target_fields: newTargets });
|
|
}}
|
|
/>
|
|
<Label
|
|
htmlFor={`${rule.id}-${field.id}`}
|
|
className="text-xs font-normal cursor-pointer"
|
|
>
|
|
{field.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rule Summary */}
|
|
{rule.trigger_field && (rule.target_fields?.length || 0) > 0 && (
|
|
<div className="text-xs bg-blue-50 border border-blue-200 rounded p-2 text-blue-800">
|
|
<strong>Summary:</strong> When "{triggerFieldData?.label || rule.trigger_field}" {rule.trigger_operator} "{String(rule.trigger_value)}", {rule.action} the following fields: {rule.target_fields?.map(id => allFields.find(f => f.id === id)?.label || id).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{(schema?.conditional_rules || []).length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
|
No conditional rules configured yet.<br />
|
|
Click "Add Rule" to create your first rule.
|
|
</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;
|