Files
emaui/apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx
2026-08-21 13:09:40 +00:00

528 lines
24 KiB
TypeScript

import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Checkbox,
Divider,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconEdit,
IconFilePlus,
IconGripVertical,
IconPlus,
IconSettings,
IconTrash,
} from '@tabler/icons-react';
import { notify, ModalFooter } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FieldType = 'text' | 'number' | 'select' | 'textarea' | 'date' | 'file';
type FieldStep = 'Vessel Details' | 'Technical & Ownership' | 'Documents';
interface FormField {
id: string;
label: string;
key: string;
type: FieldType;
step: FieldStep;
required: boolean;
placeholder: string;
helpText: string;
options: string; // comma-separated for select type
enabled: boolean;
system: boolean; // system fields cannot be deleted
}
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text Input' },
{ value: 'number', label: 'Number Input' },
{ value: 'select', label: 'Dropdown / Select' },
{ value: 'textarea', label: 'Text Area' },
{ value: 'date', label: 'Date Picker' },
{ value: 'file', label: 'File Upload' },
];
const STEPS: FieldStep[] = ['Vessel Details', 'Technical & Ownership', 'Documents'];
const TYPE_COLOR: Record<FieldType, string> = {
text: 'blue',
number: 'orange',
select: 'violet',
textarea: 'gray',
date: 'teal',
file: 'green',
};
// ---------------------------------------------------------------------------
// Default form schema — mirrors the vessel registration wizard
// ---------------------------------------------------------------------------
const DEFAULT_FIELDS: FormField[] = [
// Vessel Details
{ id: 'f1', label: 'Vessel Name', key: 'vesselName', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Lake Tana Star', helpText: '', options: '', enabled: true, system: true },
{ id: 'f2', label: 'Vessel Type', key: 'vesselType', type: 'select', step: 'Vessel Details', required: true, placeholder: 'Select type', helpText: 'Options depend on selected category', options: 'Passenger Ferry,Cargo Barge,Fishing Vessel,Tug Boat,Container Ship,Bulk Carrier,Tanker', enabled: true, system: true },
{ id: 'f3', label: 'Registration Area', key: 'registrationArea', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Bahir Dar', helpText: 'Port or lake authority area', options: '', enabled: true, system: true },
{ id: 'f4', label: 'Flag State', key: 'flagState', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Ethiopia', helpText: '', options: '', enabled: true, system: true },
{ id: 'f5', label: 'Passenger Capacity / Gross Tonnage',key: 'capacityValue', type: 'number', step: 'Vessel Details', required: true, placeholder: 'Enter value', helpText: 'Passenger count for ferries; GT for cargo', options: '', enabled: true, system: true },
{ id: 'f6', label: 'Vessel Length (m)', key: 'vesselLengthM', type: 'number', step: 'Vessel Details', required: true, placeholder: 'e.g. 32', helpText: '', options: '', enabled: true, system: false },
// Technical & Ownership
{ id: 'f7', label: 'IMO / Hull Number', key: 'imoOrHullNumber', type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. IMO9876543', helpText: '', options: '', enabled: true, system: true },
{ id: 'f8', label: 'Manufacturer / Shipyard', key: 'manufacturerShipyard',type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. Hyundai Heavy Industries', helpText: '', options: '', enabled: true, system: true },
{ id: 'f9', label: 'Year Built', key: 'yearBuilt', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 2015', helpText: '', options: '', enabled: true, system: true },
{ id: 'f10', label: 'Engine Type', key: 'engineType', type: 'select', step: 'Technical & Ownership',required: true, placeholder: 'Select engine type', helpText: '', options: 'Diesel Engine,Dual-Fuel Engine,Electric Motor,Hybrid Diesel-Electric,Steam Turbine,Gas Turbine,Outboard Motor', enabled: true, system: true },
{ id: 'f11', label: 'Engine Power (kW)', key: 'enginePowerKw', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 450', helpText: '', options: '', enabled: true, system: false },
{ id: 'f12', label: 'Number of Engines', key: 'numberOfEngines', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 2', helpText: '', options: '', enabled: true, system: false },
{ id: 'f13', label: 'Hull Material', key: 'hullMaterial', type: 'select', step: 'Technical & Ownership',required: true, placeholder: 'Select material', helpText: '', options: 'Steel,Aluminum,Fiberglass/GRP,Wood,Ferro-Cement', enabled: true, system: false },
{ id: 'f14', label: 'Owner Full Name', key: 'ownerName', type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'Legal name of the owner', helpText: '', options: '', enabled: true, system: true },
{ id: 'f15', label: 'National ID / TIN', key: 'ownerNationalIdOrTin',type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. ET-0000000', helpText: '', options: '', enabled: true, system: true },
{ id: 'f16', label: 'Owner Phone', key: 'ownerPhone', type: 'text', step: 'Technical & Ownership',required: true, placeholder: '+251 9XX XXX XXX', helpText: '', options: '', enabled: true, system: true },
{ id: 'f17', label: 'Owner Address', key: 'ownerAddress', type: 'textarea', step: 'Technical & Ownership',required: false, placeholder: 'City, Region', helpText: '', options: '', enabled: true, system: false },
// Documents
{ id: 'f18', label: 'Vessel Photos (min. 2)', key: 'vesselPhotos', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Required for all vessel types', options: '', enabled: true, system: true },
{ id: 'f19', label: 'Proof of Ownership / Bill of Sale', key: 'proofOfOwnership', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
{ id: 'f20', label: 'Ship Particulars', key: 'shipParticulars', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
{ id: 'f21', label: 'Insurance Certificate', key: 'insuranceCertificate',type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
];
function generateId() {
return 'f' + Date.now();
}
// ---------------------------------------------------------------------------
// Field row card
// ---------------------------------------------------------------------------
function FieldCard({
field,
index,
total,
onEdit,
onDelete,
onToggle,
onMoveUp,
onMoveDown,
}: {
field: FormField;
index: number;
total: number;
onEdit: () => void;
onDelete: () => void;
onToggle: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}) {
return (
<Card
withBorder
radius="sm"
p="sm"
style={{
opacity: field.enabled ? 1 : 0.5,
borderColor: field.enabled ? 'var(--mantine-color-default-border)' : 'var(--mantine-color-gray-3)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon variant="subtle" color="gray" size="sm" style={{ cursor: 'grab', flexShrink: 0 }}>
<IconGripVertical size={14} />
</ActionIcon>
<div style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text fz="sm" fw={600} truncate>{field.label}</Text>
{field.required && <Text fz="xs" c="red" fw={700}>*</Text>}
{field.system && <Badge size="xs" color="gray" variant="outline">system</Badge>}
</Group>
<Group gap="xs" mt={2}>
<Badge size="xs" color={TYPE_COLOR[field.type]} variant="light">{field.type}</Badge>
<Text fz="xs" c="dimmed" truncate>key: {field.key}</Text>
{field.helpText && <Text fz="xs" c="dimmed" truncate>· {field.helpText}</Text>}
</Group>
</div>
</Group>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<Switch
size="xs"
checked={field.enabled}
onChange={onToggle}
title={field.enabled ? 'Disable field' : 'Enable field'}
/>
<ActionIcon variant="subtle" color="gray" size="sm" disabled={index === 0} onClick={onMoveUp}>
<IconArrowUp size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" size="sm" disabled={index === total - 1} onClick={onMoveDown}>
<IconArrowDown size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={onEdit}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={field.system}
onClick={onDelete}
title={field.system ? 'System fields cannot be deleted' : 'Delete field'}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationFormBuilderPage() {
const [fields, setFields] = useState<FormField[]>(DEFAULT_FIELDS);
const [activeStep, setActiveStep] = useState<FieldStep>('Vessel Details');
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<FormField | null>(null);
const [isNew, setIsNew] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FormField | null>(null);
const [saving, setSaving] = useState(false);
// Draft state for the editor drawer
const [draftLabel, setDraftLabel] = useState('');
const [draftKey, setDraftKey] = useState('');
const [draftType, setDraftType] = useState<FieldType>('text');
const [draftStep, setDraftStep] = useState<FieldStep>('Vessel Details');
const [draftRequired, setDraftRequired] = useState(false);
const [draftPlaceholder, setDraftPlaceholder] = useState('');
const [draftHelpText, setDraftHelpText] = useState('');
const [draftOptions, setDraftOptions] = useState('');
const stepFields = fields.filter((f) => f.step === activeStep);
const totalEnabled = fields.filter((f) => f.enabled).length;
const openEdit = (field: FormField) => {
setEditing(field);
setIsNew(false);
setDraftLabel(field.label);
setDraftKey(field.key);
setDraftType(field.type);
setDraftStep(field.step);
setDraftRequired(field.required);
setDraftPlaceholder(field.placeholder);
setDraftHelpText(field.helpText);
setDraftOptions(field.options);
setDrawerOpen(true);
};
const openNew = () => {
setEditing(null);
setIsNew(true);
setDraftLabel('');
setDraftKey('');
setDraftType('text');
setDraftStep(activeStep);
setDraftRequired(false);
setDraftPlaceholder('');
setDraftHelpText('');
setDraftOptions('');
setDrawerOpen(true);
};
const saveField = () => {
if (!draftLabel.trim() || !draftKey.trim()) {
notify.error('Label and field key are required.');
return;
}
if (isNew) {
const newField: FormField = {
id: generateId(),
label: draftLabel.trim(),
key: draftKey.trim().replace(/\s+/g, '_').toLowerCase(),
type: draftType,
step: draftStep,
required: draftRequired,
placeholder: draftPlaceholder,
helpText: draftHelpText,
options: draftOptions,
enabled: true,
system: false,
};
setFields((prev) => [...prev, newField]);
notify.success('Field added.');
} else if (editing) {
setFields((prev) =>
prev.map((f) =>
f.id === editing.id
? {
...f,
label: draftLabel.trim(),
key: draftKey.trim().replace(/\s+/g, '_').toLowerCase(),
type: draftType,
step: draftStep,
required: draftRequired,
placeholder: draftPlaceholder,
helpText: draftHelpText,
options: draftOptions,
}
: f
)
);
notify.success('Field updated.');
}
setDrawerOpen(false);
};
const toggleField = (id: string) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, enabled: !f.enabled } : f)));
};
const confirmDelete = () => {
if (!deleteTarget) return;
setFields((prev) => prev.filter((f) => f.id !== deleteTarget.id));
setDeleteTarget(null);
notify.success('Field removed.');
};
const moveField = (id: string, direction: 'up' | 'down') => {
setFields((prev) => {
const stepList = prev.filter((f) => f.step === activeStep);
const otherList = prev.filter((f) => f.step !== activeStep);
const idx = stepList.findIndex((f) => f.id === id);
if (idx < 0) return prev;
const next = [...stepList];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= next.length) return prev;
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
// Rebuild with original order for other steps
return prev.map((f) => {
if (f.step !== activeStep) return f;
return next.shift()!;
});
});
};
const handleSaveSchema = async () => {
setSaving(true);
await new Promise((r) => setTimeout(r, 600));
setSaving(false);
notify.success('Form schema saved successfully.');
};
const stepCounts = STEPS.map((s) => ({
step: s,
total: fields.filter((f) => f.step === s).length,
enabled: fields.filter((f) => f.step === s && f.enabled).length,
}));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="teal" variant="light">
<IconSettings size={24} />
</ThemeIcon>
<div>
<Title order={2}>Vessel Registration Form Builder</Title>
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
</div>
</Group>
<Group gap="sm">
<Button leftSection={<IconPlus size={15} />} variant="default" onClick={openNew}>
Add Field
</Button>
<Button leftSection={<IconFilePlus size={15} />} color="teal" loading={saving} onClick={handleSaveSchema}>
Save Schema
</Button>
</Group>
</Group>
{/* Summary stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Fields', value: fields.length, color: 'teal' },
{ label: 'Active Fields', value: totalEnabled, color: 'blue' },
{ label: 'Disabled Fields', value: fields.length - totalEnabled, color: 'gray' },
{ label: 'Required Fields', value: fields.filter((f) => f.required && f.enabled).length, color: 'orange' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xl" fw={800} c={`${s.color}.6`}>{s.value}</Text>
<Text fz="xs" c="dimmed">{s.label}</Text>
</Card>
))}
</SimpleGrid>
{/* Step tabs */}
<Group gap="sm">
{stepCounts.map(({ step, total, enabled }) => (
<Button
key={step}
variant={activeStep === step ? 'filled' : 'default'}
color={activeStep === step ? 'teal' : undefined}
size="sm"
onClick={() => setActiveStep(step)}
rightSection={
<Badge
size="xs"
color={activeStep === step ? 'white' : 'gray'}
variant="filled"
circle
>
{enabled}/{total}
</Badge>
}
>
{step}
</Button>
))}
</Group>
{/* Field list */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Text fw={700} fz="sm">{activeStep}</Text>
<Text fz="xs" c="dimmed">
{stepFields.filter((f) => f.enabled).length} of {stepFields.length} fields active
</Text>
</Group>
{stepFields.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="xl">
No fields in this step. Click "Add Field" to add one.
</Text>
) : (
<Stack gap="xs">
{stepFields.map((field, i) => (
<FieldCard
key={field.id}
field={field}
index={i}
total={stepFields.length}
onEdit={() => openEdit(field)}
onDelete={() => setDeleteTarget(field)}
onToggle={() => toggleField(field.id)}
onMoveUp={() => moveField(field.id, 'up')}
onMoveDown={() => moveField(field.id, 'down')}
/>
))}
</Stack>
)}
</Paper>
{/* Edit / Add Drawer */}
<Drawer
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
position="right"
size="md"
title={<Text fw={700}>{isNew ? 'Add New Field' : `Edit — ${editing?.label}`}</Text>}
>
<Stack gap="md">
<TextInput
label="Field Label"
placeholder="e.g. Vessel Name"
required
value={draftLabel}
onChange={(e) => {
const val = e.currentTarget.value;
setDraftLabel(val);
if (isNew)
setDraftKey(val.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''));
}}
/>
<TextInput
label="Field Key (internal)"
placeholder="e.g. vessel_name"
required
value={draftKey}
onChange={(e) =>
setDraftKey(
e.currentTarget.value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
)
}
description="Used as the API field name. Letters, numbers and underscores only."
/>
<Select
label="Field Type"
data={FIELD_TYPES}
value={draftType}
onChange={(v) => setDraftType((v as FieldType) ?? 'text')}
/>
<Select
label="Step / Section"
data={STEPS.map((s) => ({ value: s, label: s }))}
value={draftStep}
onChange={(v) => setDraftStep((v as FieldStep) ?? 'Vessel Details')}
/>
<TextInput
label="Placeholder Text"
placeholder="e.g. Enter vessel name"
value={draftPlaceholder}
onChange={(e) => setDraftPlaceholder(e.currentTarget.value)}
/>
<TextInput
label="Help Text"
placeholder="Short hint shown below the field"
value={draftHelpText}
onChange={(e) => setDraftHelpText(e.currentTarget.value)}
/>
{draftType === 'select' && (
<Textarea
label="Options (comma-separated)"
placeholder="Option A,Option B,Option C"
value={draftOptions}
onChange={(e) => setDraftOptions(e.currentTarget.value)}
rows={3}
description="Each comma-separated value becomes a dropdown option."
/>
)}
<Checkbox
label="Required field"
checked={draftRequired}
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
/>
<Divider />
<ModalFooter>
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button color="teal" onClick={saveField}>
{isNew ? 'Add Field' : 'Save Changes'}
</Button>
</ModalFooter>
</Stack>
</Drawer>
{/* Delete confirm modal */}
<Modal
opened={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Field"
size="sm"
>
<Stack gap="md">
<Text fz="sm">
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
This cannot be undone.
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}