mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(vessel-registration): implement vessel registration feature with multi-step application process
- Added VesselRegistrationApplicationPage for submitting new vessel registrations. - Created VesselRegistrationPage to list user's registrations and navigate to details. - Implemented VesselRegistrationStatusPage to display registration status and download certificates. - Integrated mock data for registrations and certificates. - Updated navigation and routing to include vessel registration paths. - Added translations for vessel registration in English and Amharic. - Documented the vessel registration workflow and API integration notes.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconSailboat,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
addRegistration,
|
||||
ENGINE_TYPES,
|
||||
HULL_MATERIALS,
|
||||
REQUIRED_DOCS,
|
||||
VESSEL_TYPES,
|
||||
type VesselCategory,
|
||||
} from '../mock';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function VesselRegistrationApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
// Step 0 — category
|
||||
const [category, setCategory] = useState<VesselCategory | null>(null);
|
||||
|
||||
// Step 1 — vessel details
|
||||
const [vesselName, setVesselName] = useState('');
|
||||
const [vesselType, setVesselType] = useState<string | null>(null);
|
||||
const [registrationArea, setRegistrationArea] = useState('');
|
||||
const [flagState, setFlagState] = useState('Ethiopia');
|
||||
const [passengerCapacity, setPassengerCapacity] = useState('');
|
||||
const [grossTonnage, setGrossTonnage] = useState('');
|
||||
const [length, setLength] = useState('');
|
||||
const [breadth, setBreadth] = useState('');
|
||||
const [depth, setDepth] = useState('');
|
||||
|
||||
// Step 2 — technical + ownership
|
||||
const [imoNumber, setImoNumber] = useState('');
|
||||
const [hullNumber, setHullNumber] = useState('');
|
||||
const [shipyard, setShipyard] = useState('');
|
||||
const [yearBuilt, setYearBuilt] = useState('');
|
||||
const [engineType, setEngineType] = useState<string | null>(null);
|
||||
const [engineNumber, setEngineNumber] = useState('');
|
||||
const [enginePower, setEnginePower] = useState('');
|
||||
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
|
||||
const [ownerName, setOwnerName] = useState(user?.name?.en || user?.username || '');
|
||||
const [ownerIdOrTin, setOwnerIdOrTin] = useState('');
|
||||
const [ownerPhone, setOwnerPhone] = useState('');
|
||||
const [ownerEmail, setOwnerEmail] = useState('');
|
||||
const [ownerAddress, setOwnerAddress] = useState('');
|
||||
|
||||
// Step 3 — documents
|
||||
const [docs, setDocs] = useState<Record<string, File[] | null>>({});
|
||||
|
||||
const handleSelectCategory = (next: VesselCategory) => {
|
||||
setCategory(next);
|
||||
setVesselType(null);
|
||||
setImoNumber('');
|
||||
setHullNumber('');
|
||||
setPassengerCapacity('');
|
||||
setGrossTonnage('');
|
||||
setDocs({});
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const requiredDocs = category ? REQUIRED_DOCS[category] : [];
|
||||
|
||||
const step1Ok = !!vesselName && !!vesselType && !!registrationArea && !!flagState
|
||||
&& !!length && !!breadth && !!depth
|
||||
&& (category === 'Sea-going' ? !!grossTonnage : !!passengerCapacity);
|
||||
const step2Ok = !!shipyard && !!yearBuilt && !!engineType && !!engineNumber && !!enginePower && !!hullMaterial
|
||||
&& (category === 'Sea-going' ? !!imoNumber : !!hullNumber)
|
||||
&& !!ownerName && !!ownerIdOrTin && !!ownerPhone && EMAIL_RE.test(ownerEmail) && !!ownerAddress;
|
||||
const step3Ok = requiredDocs.every((slot) => {
|
||||
const files = docs[slot.key];
|
||||
return !!files && files.length >= (slot.minCount ?? 1);
|
||||
});
|
||||
|
||||
const docStatus = (slot: (typeof requiredDocs)[number]): { label: string; color: string } => {
|
||||
const files = docs[slot.key];
|
||||
if (!files || files.length === 0) return { label: 'Empty', color: 'gray' };
|
||||
if (files.length < (slot.minCount ?? 1)) return { label: 'Missing Required Document', color: 'red' };
|
||||
return { label: 'Uploaded', color: 'teal' };
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!category || !vesselType || !engineType || !hullMaterial) return;
|
||||
const created = addRegistration({
|
||||
category,
|
||||
vesselName,
|
||||
vesselType,
|
||||
registrationArea,
|
||||
flagState,
|
||||
passengerCapacity: category === 'Inland Waterway' ? passengerCapacity : undefined,
|
||||
grossTonnage: category === 'Sea-going' ? grossTonnage : undefined,
|
||||
length,
|
||||
breadth,
|
||||
depth,
|
||||
imoNumber: category === 'Sea-going' ? imoNumber : undefined,
|
||||
hullNumber: category === 'Inland Waterway' ? hullNumber : undefined,
|
||||
shipyard,
|
||||
yearBuilt,
|
||||
engineType,
|
||||
engineNumber,
|
||||
enginePower,
|
||||
hullMaterial,
|
||||
owner: {
|
||||
name: ownerName,
|
||||
idOrTin: ownerIdOrTin,
|
||||
phone: ownerPhone,
|
||||
email: ownerEmail,
|
||||
address: ownerAddress,
|
||||
},
|
||||
});
|
||||
notify.success('Registration submitted. SMS and email confirmation sent.');
|
||||
navigate(`/vessel-registrations/${created.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Register a Vessel</Title>
|
||||
<Text fz="sm" c="dimmed">Submit a new vessel registration application.</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Stepper active={step} size="sm" color="blue" onStepClick={(s) => { if (category && s < step) setStep(s); }}>
|
||||
<Stepper.Step label="Category" description="Vessel type" />
|
||||
<Stepper.Step label="Vessel Details" description="Identification" />
|
||||
<Stepper.Step label="Technical & Ownership" description="Specs & owner" />
|
||||
<Stepper.Step label="Documents" description="Required uploads" />
|
||||
<Stepper.Step label="Review & Submit" description="Final check" />
|
||||
</Stepper>
|
||||
|
||||
{/* Step 0 — Category */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Select Vessel Category</Text>
|
||||
<Text fz="sm" c="dimmed">Choose the category that matches your vessel. This determines the fields, documents, and certificates for your registration.</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={() => handleSelectCategory('Inland Waterway')}
|
||||
style={{ cursor: 'pointer', borderColor: category === 'Inland Waterway' ? 'var(--mantine-color-blue-5)' : undefined }}
|
||||
>
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light" mb="sm"><IconSailboat size={22} /></ThemeIcon>
|
||||
<Text fw={700}>Inland Waterway Vessel</Text>
|
||||
<Text fz="sm" c="dimmed">Boats, barges, ferries and other vessels operating on lakes and rivers.</Text>
|
||||
</Card>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={() => handleSelectCategory('Sea-going')}
|
||||
style={{ cursor: 'pointer', borderColor: category === 'Sea-going' ? 'var(--mantine-color-blue-5)' : undefined }}
|
||||
>
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light" mb="sm"><IconAnchor size={22} /></ThemeIcon>
|
||||
<Text fw={700}>Sea-going Vessel (International)</Text>
|
||||
<Text fz="sm" c="dimmed">Vessels engaged in international sea voyages.</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — Vessel details */}
|
||||
{step === 1 && category && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Vessel Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Vessel Name" value={vesselName} onChange={(e) => setVesselName(e.currentTarget.value)} required />
|
||||
<Select label="Vessel Type" placeholder="Select a type…" data={VESSEL_TYPES[category]} value={vesselType} onChange={setVesselType} required />
|
||||
<TextInput label="Registration Area" value={registrationArea} onChange={(e) => setRegistrationArea(e.currentTarget.value)} required />
|
||||
<TextInput label="Flag State" value={flagState} onChange={(e) => setFlagState(e.currentTarget.value)} required />
|
||||
{category === 'Inland Waterway' ? (
|
||||
<TextInput label="Passenger Capacity" value={passengerCapacity} onChange={(e) => setPassengerCapacity(e.currentTarget.value)} required />
|
||||
) : (
|
||||
<TextInput label="Gross Tonnage (GT)" value={grossTonnage} onChange={(e) => setGrossTonnage(e.currentTarget.value)} required />
|
||||
)}
|
||||
<TextInput label="Length (m)" value={length} onChange={(e) => setLength(e.currentTarget.value)} required />
|
||||
<TextInput label="Breadth (m)" value={breadth} onChange={(e) => setBreadth(e.currentTarget.value)} required />
|
||||
<TextInput label="Depth (m)" value={depth} onChange={(e) => setDepth(e.currentTarget.value)} required />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Technical + ownership */}
|
||||
{step === 2 && category && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="lg">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Technical Specifications</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{category === 'Sea-going' ? (
|
||||
<TextInput label="IMO Number" value={imoNumber} onChange={(e) => setImoNumber(e.currentTarget.value)} required />
|
||||
) : (
|
||||
<TextInput label="Hull Number" value={hullNumber} onChange={(e) => setHullNumber(e.currentTarget.value)} required />
|
||||
)}
|
||||
<TextInput label="Shipyard" value={shipyard} onChange={(e) => setShipyard(e.currentTarget.value)} required />
|
||||
<TextInput label="Year Built" value={yearBuilt} onChange={(e) => setYearBuilt(e.currentTarget.value)} required />
|
||||
<Select label="Engine Type" placeholder="Select…" data={[...ENGINE_TYPES]} value={engineType} onChange={setEngineType} required />
|
||||
<TextInput label="Engine Number" value={engineNumber} onChange={(e) => setEngineNumber(e.currentTarget.value)} required />
|
||||
<TextInput label="Engine Power" value={enginePower} onChange={(e) => setEnginePower(e.currentTarget.value)} required />
|
||||
<Select label="Hull Material" placeholder="Select…" data={[...HULL_MATERIALS]} value={hullMaterial} onChange={setHullMaterial} required />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Ownership Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Owner Name" value={ownerName} onChange={(e) => setOwnerName(e.currentTarget.value)} required />
|
||||
<TextInput label="National ID / TIN" value={ownerIdOrTin} onChange={(e) => setOwnerIdOrTin(e.currentTarget.value)} required />
|
||||
<TextInput label="Phone Number" value={ownerPhone} onChange={(e) => setOwnerPhone(e.currentTarget.value)} required />
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
value={ownerEmail}
|
||||
onChange={(e) => setOwnerEmail(e.currentTarget.value)}
|
||||
error={ownerEmail && !EMAIL_RE.test(ownerEmail) ? 'Enter a valid email' : undefined}
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea label="Address" value={ownerAddress} onChange={(e) => setOwnerAddress(e.currentTarget.value)} required />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Documents */}
|
||||
{step === 3 && category && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Required Documents</Text>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">Required documents depend on your vessel category. All mandatory documents must be uploaded before you can proceed.</Text>
|
||||
</Alert>
|
||||
{requiredDocs.map((slot) => {
|
||||
const status = docStatus(slot);
|
||||
return (
|
||||
<FileInput
|
||||
key={slot.key}
|
||||
label={
|
||||
<Group gap={4}>
|
||||
<Text fz="sm" fw={500}>{slot.label}{slot.minCount ? ` (minimum ${slot.minCount})` : ''}</Text>
|
||||
<Badge size="xs" color={status.color} variant="light">{status.label}</Badge>
|
||||
</Group>
|
||||
}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={docs[slot.key] ?? []}
|
||||
onChange={(files) => setDocs((prev) => ({ ...prev, [slot.key]: files }))}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 4 — Review */}
|
||||
{step === 4 && category && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="lg">
|
||||
<Text fw={700}>Review & Submit</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{[
|
||||
['Category', category],
|
||||
['Vessel Name', vesselName],
|
||||
['Vessel Type', vesselType ?? ''],
|
||||
['Registration Area', registrationArea],
|
||||
['Flag State', flagState],
|
||||
[category === 'Sea-going' ? 'Gross Tonnage (GT)' : 'Passenger Capacity', category === 'Sea-going' ? grossTonnage : passengerCapacity],
|
||||
['Length / Breadth / Depth', `${length} / ${breadth} / ${depth}`],
|
||||
[category === 'Sea-going' ? 'IMO Number' : 'Hull Number', category === 'Sea-going' ? imoNumber : hullNumber],
|
||||
['Shipyard', shipyard],
|
||||
['Year Built', yearBuilt],
|
||||
['Engine Type', engineType ?? ''],
|
||||
['Engine Number', engineNumber],
|
||||
['Engine Power', enginePower],
|
||||
['Hull Material', hullMaterial ?? ''],
|
||||
['Owner Name', ownerName],
|
||||
['National ID / TIN', ownerIdOrTin],
|
||||
['Phone', ownerPhone],
|
||||
['Email', ownerEmail],
|
||||
['Address', ownerAddress],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider />
|
||||
<Stack gap={4}>
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed">Documents</Text>
|
||||
{requiredDocs.map((slot) => (
|
||||
<Text key={slot.key} fz="sm" c="blue.7">
|
||||
{slot.label}: {(docs[slot.key] ?? []).map((f) => f.name).join(', ') || '—'}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />}>
|
||||
<Text fz="xs">By submitting you confirm the information above is accurate and the uploaded documents are genuine.</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
{step > 0 && (
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep((s) => s - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 4 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 1 && !step1Ok) || (step === 2 && !step2Ok) || (step === 3 && !step3Ok)}
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCheck size={14} />} onClick={handleSubmit}>
|
||||
Submit Registration
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user