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:
estifanos
2026-07-24 07:44:45 +00:00
parent 60ef3fc8b8
commit e482a4aca6
17 changed files with 2120 additions and 0 deletions

View File

@@ -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>
);
}