feat: add vessel registration

This commit is contained in:
mengstabketemaw
2026-07-03 16:47:19 +03:00
parent 0ba70daf00
commit 39a16e82ea
16 changed files with 4439 additions and 14 deletions

View File

@@ -0,0 +1,114 @@
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
// Sample mock — in production this comes from the API
const MOCK_MY_VESSELS = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
status: 'Under Review',
submittedDate: '2024-03-15',
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray', 'Under Review': 'yellow', Approved: 'teal', Rejected: 'red', 'Correction Required': 'orange',
};
export function VesselOwnerDashboardPage() {
const navigate = useNavigate();
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>My Vessels</Title>
<Text fz="sm" c="dimmed">Manage your vessel registrations</Text>
</div>
</Group>
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
Register New Vessel
</Button>
</Group>
{MOCK_MY_VESSELS.length === 0 ? (
<Paper withBorder radius="lg" p="xl">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
<IconAnchor size={30} />
</ThemeIcon>
<Title order={4} ta="center">No Vessels Registered</Title>
<Text fz="sm" c="dimmed" ta="center" maw={400}>
You haven't registered any vessels yet. Click "Register New Vessel" to begin the application process.
</Text>
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_MY_VESSELS.map((v) => (
<Card key={v.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconShip size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{v.vesselName}</Text>
<Text fz="xs" c="dimmed">{v.id}</Text>
</div>
</Group>
<Text fz="xs" fw={600} c={`${STATUS_COLOR[v.status]}.6`}>{v.status}</Text>
</Group>
<Stack gap={4} mt="sm">
<Group gap="xs">
<Text fz="xs" c="dimmed">Category:</Text>
<Text fz="xs">{v.category}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Type:</Text>
<Text fz="xs">{v.vesselType}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Submitted:</Text>
<Text fz="xs">{v.submittedDate}</Text>
</Group>
</Stack>
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
View Details
</Button>
</Card>
))}
</SimpleGrid>
)}
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
</Alert>
</Stack>
);
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Center,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconLock,
IconMail,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
export function VesselOwnerLoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginTrigger] = useApiMutation<{ token: string; user: { id: string; name: string } }>();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
setError('Please enter your email and password.');
return;
}
setError('');
setLoading(true);
try {
await loginTrigger({
url: '/auth/vessel-owner/login',
method: 'POST',
body: { email, password },
}).unwrap();
notify.success('Login successful. Welcome!');
navigate('/vessel-owner/dashboard');
} catch {
setError('Invalid email or password. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={440} px="md">
{/* Brand */}
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Vessel Owner Portal</Title>
<Text fz="sm" c="dimmed" ta="center">
Ethiopian Maritime Affairs Authority
</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Sign In</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{error}
</Alert>
)}
<Stack gap="md">
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
leftSection={<IconLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<Anchor fz="sm" ta="right" onClick={() => navigate('/vessel-owner/forgot-password')}>
Forgot password?
</Anchor>
<Button fullWidth size="md" loading={loading} onClick={handleLogin} leftSection={<IconAnchor size={16} />}>
Sign In
</Button>
</Stack>
<Divider my="md" label="Don't have an account?" labelPosition="center" />
<Button
fullWidth
variant="light"
onClick={() => navigate('/vessel-owner/register')}
>
Create Vessel Owner Account
</Button>
</Paper>
<Text fz="xs" c="dimmed" ta="center">
This portal is exclusively for vessel owners. For seafarer services,{' '}
<Anchor fz="xs" onClick={() => navigate('/login')}>sign in here</Anchor>.
</Text>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const OWNER_TYPES = [
'Individual (Private Owner)',
'Private Company / PLC',
'State Enterprise',
'NGO / Non-Profit',
'Government Agency',
];
export function VesselOwnerRegisterPage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [ownerType, setOwnerType] = useState<string | null>(null);
const [nationalIdOrTin, setNationalIdOrTin] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
setError('Please fill in all required fields. Passwords must match.');
return;
}
setError('');
setLoading(true);
try {
await registerTrigger({
url: '/auth/vessel-owner/register',
method: 'POST',
body: { fullName, email, phone, ownerType, nationalIdOrTin, password },
}).unwrap();
setSuccess(true);
notify.success('Account created! You can now sign in.');
} catch {
setError('Registration failed. This email may already be registered.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Paper withBorder radius="lg" p="xl" w="100%" maw={440} shadow="sm" mx="md">
<Stack align="center" gap="md">
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCheck size={30} />
</ThemeIcon>
<Title order={3} ta="center">Account Created!</Title>
<Text fz="sm" c="dimmed" ta="center">
Your vessel owner account has been created. You can now sign in and submit vessel registration applications.
</Text>
<Button fullWidth onClick={() => navigate('/vessel-owner/login')}>
Sign In Now
</Button>
</Stack>
</Paper>
</Box>
);
}
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={540} px="md">
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Create Vessel Owner Account</Title>
<Text fz="sm" c="dimmed" ta="center">Ethiopian Maritime Affairs Authority</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Owner Registration</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">{error}</Alert>
)}
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Full Name / Company Name"
placeholder="e.g. Abebe Girma"
leftSection={<IconUser size={16} />}
required
value={fullName}
onChange={(e) => setFullName(e.currentTarget.value)}
/>
<Select
label="Owner Type"
placeholder="Select type"
required
data={OWNER_TYPES}
value={ownerType}
onChange={setOwnerType}
/>
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="ET-0000000 or TIN"
required
value={nationalIdOrTin}
onChange={(e) => setNationalIdOrTin(e.currentTarget.value)}
/>
</SimpleGrid>
<Divider label="Set Password" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="Min. 8 characters"
leftSection={<IconLock size={16} />}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<PasswordInput
label="Confirm Password"
placeholder="Repeat password"
leftSection={<IconLock size={16} />}
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
error={confirmPassword && password !== confirmPassword ? 'Passwords do not match' : undefined}
/>
</SimpleGrid>
<Button fullWidth size="md" loading={loading} disabled={!canSubmit} onClick={handleRegister}>
Create Account
</Button>
</Stack>
<Divider my="md" label="Already have an account?" labelPosition="center" />
<Button fullWidth variant="light" onClick={() => navigate('/vessel-owner/login')}>
Sign In
</Button>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,440 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowRight,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// Minimal vessel type for the approved vessel list
interface ApprovedVessel {
id: string;
vesselName: string;
category: string;
vesselType: string;
ownerName: string;
ownerNationalIdOrTin: string;
ownerPhone: string;
status: string;
}
const MOCK_APPROVED_VESSELS: ApprovedVessel[] = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
ownerName: 'Abebe Girma',
ownerNationalIdOrTin: 'ET-9812345',
ownerPhone: '+251 911 234 567',
status: 'Approved',
},
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
export interface OwnershipTransferRequest {
id: string;
vesselId: string;
vesselName: string;
category: string;
vesselType: string;
currentOwnerName: string;
currentOwnerIdOrTin: string;
currentOwnerPhone: string;
newOwnerName: string;
newOwnerIdOrTin: string;
newOwnerPhone: string;
newOwnerEmail: string;
newOwnerAddress: string;
transferReason: string;
remarks: string;
status: TransferStatus;
submittedDate: string;
approvalDate: string | null;
}
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
{
id: 'OT-2024-001',
vesselId: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
currentOwnerName: 'Abebe Girma',
currentOwnerIdOrTin: 'ET-9812345',
currentOwnerPhone: '+251 911 234 567',
newOwnerName: 'Tigist Haile',
newOwnerIdOrTin: 'ET-7743210',
newOwnerPhone: '+251 922 876 543',
newOwnerEmail: 'tigist.haile@email.com',
newOwnerAddress: 'Bahir Dar, Amhara Region',
transferReason: 'Sale',
remarks: 'Vessel sold to new owner. Bill of sale attached.',
status: 'Pending',
submittedDate: '2024-06-01',
approvalDate: null,
},
];
const TRANSFER_REASONS = [
'Sale / Purchase',
'Inheritance',
'Gift / Donation',
'Corporate Restructuring',
'Court Order',
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
// ---------------------------------------------------------------------------
// Transfer request card
// ---------------------------------------------------------------------------
function TransferCard({ req }: { req: OwnershipTransferRequest }) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="violet" variant="light">
<IconTransferIn size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{req.vesselName}</Text>
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'From', value: req.currentOwnerName },
{ label: 'To', value: req.newOwnerName },
{ label: 'Reason', value: req.transferReason },
{ label: 'Submitted', value: req.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value}</Text>
</div>
))}
</SimpleGrid>
{req.status === 'Approved' && (
<Alert icon={<IconCircleCheck size={14} />} color="teal" mt="sm" py="xs">
Transfer approved on {req.approvalDate}. New certificates issued to {req.newOwnerName}.
</Alert>
)}
{req.status === 'Rejected' && req.remarks && (
<Alert icon={<IconAlertCircle size={14} />} color="red" mt="sm" py="xs">
Rejected: {req.remarks}
</Alert>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function OwnershipTransferPage() {
const navigate = useNavigate();
const [myVessels, setMyVessels] = useState<ApprovedVessel[]>([]);
const [transfers, setTransfers] = useState<OwnershipTransferRequest[]>(MOCK_TRANSFER_REQUESTS);
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [fetchTrigger] = useApiMutation<ApprovedVessel[]>();
const [submitTrigger] = useApiMutation<{ id: string }>();
const fetched = useRef(false);
// Form state
const [selectedVesselId, setSelectedVesselId] = useState<string | null>(null);
const [newOwnerName, setNewOwnerName] = useState('');
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
const [newOwnerPhone, setNewOwnerPhone] = useState('');
const [newOwnerEmail, setNewOwnerEmail] = useState('');
const [newOwnerAddress, setNewOwnerAddress] = useState('');
const [transferReason, setTransferReason] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [billOfSale, setBillOfSale] = useState<File | null>(null);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setMyVessels(Array.isArray(data) ? data : [data]))
.catch(() => {
// Fall back to mock approved vessels
setMyVessels(MOCK_APPROVED_VESSELS);
});
}, [fetchTrigger]);
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
const resetForm = () => {
setSelectedVesselId(null);
setNewOwnerName('');
setNewOwnerIdOrTin('');
setNewOwnerPhone('');
setNewOwnerEmail('');
setNewOwnerAddress('');
setTransferReason(null);
setNotes('');
setBillOfSale(null);
};
const handleSubmit = async () => {
if (!selectedVessel) return;
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-ownership-transfers',
method: 'POST',
body: {
vesselId: selectedVessel.id,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail,
newOwnerAddress, transferReason, notes,
},
}).unwrap();
// Optimistic local update
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted successfully.');
} catch {
// Still add optimistically on API error (mock mode)
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted.');
} finally {
setSubmitting(false);
}
};
const vesselOptions = myVessels
.filter((v) => v.status === 'Approved')
.map((v) => ({ value: v.id, label: `${v.vesselName} (${v.id})` }));
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer</Title>
<Text fz="sm" c="dimmed">Request transfer of vessel ownership to another party</Text>
</div>
</Group>
<Button
leftSection={<IconTransferIn size={16} />}
color="violet"
onClick={() => setModalOpen(true)}
disabled={vesselOptions.length === 0}
>
Request Transfer
</Button>
</Group>
{vesselOptions.length === 0 && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
You must have at least one <strong>approved</strong> vessel registration to request an ownership transfer.{' '}
<Text span fz="sm" c="blue.6" style={{ cursor: 'pointer' }} onClick={() => navigate('/vessel-registration')}>
View my registrations
</Text>
</Alert>
)}
{/* How it works */}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-violet-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={16} color="var(--mantine-color-violet-7)" />
<Text fw={600} fz="sm" c="violet.7">How Ownership Transfer Works</Text>
</Group>
<Stack gap={6}>
{[
"Submit a transfer request with the new owner's details and a Bill of Sale",
'The Maritime Authority reviews and verifies the transfer documents',
'Upon approval, ownership is officially transferred in the registry',
'New certificates are automatically generated for the new owner',
'The new owner receives: Certificate of Nationality, Certificate of Ownership, Certificate of Registration (sea-going) or Inland Registration Certificate (inland)',
].map((step, i) => (
<Group key={i} gap="xs" align="flex-start">
<ThemeIcon size={20} radius="xl" color="violet" variant="light" style={{ flexShrink: 0, marginTop: 2 }}>
<Text fz="xs" fw={700}>{i + 1}</Text>
</ThemeIcon>
<Text fz="sm">{step}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Existing transfer requests */}
<div>
<Text fw={700} fz="sm" mb="sm">My Transfer Requests</Text>
{transfers.length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Text fz="sm" c="dimmed" ta="center">No transfer requests submitted yet.</Text>
</Paper>
) : (
<Stack gap="sm">
{transfers.map((req) => <TransferCard key={req.id} req={req} />)}
</Stack>
)}
</div>
{/* Transfer request modal */}
<Modal
opened={modalOpen}
onClose={() => { setModalOpen(false); resetForm(); }}
title="Request Ownership Transfer"
size="lg"
>
<Stack gap="md">
<Alert icon={<IconAlertCircle size={15} />} color="orange" variant="light">
Ownership transfer is permanent. Ensure all details are correct before submitting.
</Alert>
<Select
label="Select Vessel"
placeholder="Choose an approved vessel"
required
data={vesselOptions}
value={selectedVesselId}
onChange={setSelectedVesselId}
/>
{selectedVessel && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={4}>Current Owner</Text>
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Name</Text><Text fz="sm">{selectedVessel.ownerName}</Text></div>
<div><Text fz="xs" c="dimmed">ID / TIN</Text><Text fz="sm">{selectedVessel.ownerNationalIdOrTin}</Text></div>
</SimpleGrid>
</Paper>
)}
<Divider label="New Owner Details" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
</SimpleGrid>
<Divider label="Supporting Document" labelPosition="center" />
{/* Bill of Sale upload */}
<Card withBorder radius="md" p="md" style={{ borderStyle: 'dashed', borderColor: billOfSale ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{ width: rem(44), height: rem(44), borderRadius: rem(8), background: 'var(--mantine-color-violet-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<IconFileDescription size={22} color="var(--mantine-color-violet-6)" />
</Box>
<div>
<Text fw={600} fz="sm">Bill of Sale / Transfer Document <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">Legal document confirming the transfer of ownership</Text>
</div>
</Group>
{billOfSale ? (
<Group gap="xs">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{billOfSale.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => setBillOfSale(null)}>Remove</Button>
</Group>
) : (
<FileButton onChange={setBillOfSale} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
<Textarea label="Additional Notes" placeholder="Any additional information for the authority..." value={notes} onChange={(e) => setNotes(e.currentTarget.value)} rows={3} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => { setModalOpen(false); resetForm(); }}>Cancel</Button>
<Button color="violet" disabled={!canSubmit} loading={submitting} leftSection={<IconArrowRight size={15} />} onClick={handleSubmit}>
Submit Transfer Request
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,678 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'Vessel Category' },
{ label: 'Vessel Details' },
{ label: 'Technical & Ownership' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
const VESSEL_TYPES_INLAND = [
'Passenger Ferry', 'Cargo Barge', 'Fishing Vessel', 'Tug Boat',
'Dredger', 'Patrol/Inspection Boat', 'Pleasure Craft', 'Water Taxi',
];
const VESSEL_TYPES_SEAGOING = [
'Container Ship', 'Bulk Carrier', 'Tanker', 'General Cargo',
'Ro-Ro Vessel', 'Passenger/Cruise Ship', 'Fishing Vessel', 'Trawler',
'Yacht/Pleasure Craft', 'Chemical Tanker', 'LPG Carrier', 'Multi-Purpose Vessel',
];
const ENGINE_TYPES = [
'Diesel Engine', 'Dual-Fuel Engine', 'Electric Motor', 'Hybrid Diesel-Electric',
'Steam Turbine', 'Gas Turbine', 'Outboard Motor', 'Inboard Petrol Engine',
];
const HULL_MATERIALS = [
'Steel', 'Aluminum', 'Fiberglass/GRP', 'Wood', 'Ferro-Cement',
];
const PASSENGER_VESSEL_TYPES = new Set([
'Passenger Ferry', 'Passenger/Cruise Ship', 'Water Taxi', 'Yacht/Pleasure Craft', 'Pleasure Craft',
]);
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
}
// ---------------------------------------------------------------------------
// Step indicator (matches SeafarerRegistrationPage pattern)
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function DocCard({
slot,
file,
onFile,
}: {
slot: DocSlot;
file: File | null;
onFile: (f: File | null) => void;
}) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card
withBorder
radius="md"
p="md"
style={{
borderStyle: 'dashed',
borderColor: file
? 'var(--mantine-color-teal-5)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box
style={{
width: rem(44),
height: rem(44),
borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">
{slot.label}
{slot.required && <Text span c="red" ml={3}>*</Text>}
</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => { onFile(null); resetRef.current?.(); }}
>
Remove
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="default" {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Category
const [category, setCategory] = useState<string | null>(null);
// Step 1 — Vessel Details
const [vesselName, setVesselName] = useState('');
const [vesselType, setVesselType] = useState<string | null>(null);
const [capacityValue, setCapacityValue] = useState<string | number>('');
const [vesselLengthM, setVesselLengthM] = useState<string | number>('');
const [flagState, setFlagState] = useState('Ethiopia');
const [portOfRegistry, setPortOfRegistry] = useState('');
// Step 2 — Technical & Ownership
const [imoOrHullNumber, setImoOrHullNumber] = useState('');
const [manufacturerShipyard, setManufacturerShipyard] = useState('');
const [yearBuilt, setYearBuilt] = useState<string | number>('');
const [engineType, setEngineType] = useState<string | null>(null);
const [enginePowerKw, setEnginePowerKw] = useState<string | number>('');
const [numberOfEngines, setNumberOfEngines] = useState<string | number>('');
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
const [ownerName, setOwnerName] = useState('');
const [ownerNationalIdOrTin, setOwnerNationalIdOrTin] = useState('');
const [ownerPhone, setOwnerPhone] = useState('');
const [ownerAddress, setOwnerAddress] = useState('');
// Step 3 — Documents
const [files, setFiles] = useState<Record<string, File | null>>({
vesselPhotos: null, proofOfOwnership: null, shipParticulars: null, insuranceCertificate: null,
});
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
// Reset vessel type when category changes
useEffect(() => { setVesselType(null); }, [category]);
// Derived
const vesselTypeOptions = category === 'Inland Waterway Vessel' ? VESSEL_TYPES_INLAND : VESSEL_TYPES_SEAGOING;
const capacityLabel = PASSENGER_VESSEL_TYPES.has(vesselType ?? '') ? 'Passenger Capacity' : 'Gross Tonnage (GT)';
const idLabel = category === 'Sea-going Vessel (International)' ? 'IMO Number' : 'Hull/Registration Number';
// Inland: only vessel photos required
// Sea-going: vessel photos + proof of ownership + ship particulars + insurance
const docSlots: DocSlot[] = category === 'Inland Waterway Vessel'
? [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
]
: [
{ key: 'vesselPhotos', label: 'Vessel Photos (min. 2)', description: 'Clear exterior and interior photos of the vessel', required: true, icon: IconCamera },
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', description: 'Legal document proving ownership of the vessel', required: true, icon: IconFileDescription },
{ key: 'shipParticulars', label: 'Ship Particulars', description: 'Detailed technical specifications issued by the shipyard', required: true, icon: IconId },
{ key: 'insuranceCertificate', label: 'Insurance Certificate', description: 'Valid hull and machinery insurance policy', required: true, icon: IconShieldCheck },
];
const canNext = () => {
if (active === 0) return !!category;
if (active === 1) return (
!!vesselName.trim() && !!vesselType && !!capacityValue && !!vesselLengthM &&
!!flagState.trim() && !!portOfRegistry.trim()
);
if (active === 2) return (
!!imoOrHullNumber.trim() && !!manufacturerShipyard.trim() && !!yearBuilt &&
!!engineType && !!enginePowerKw && !!numberOfEngines && !!hullMaterial &&
!!ownerName.trim() && !!ownerNationalIdOrTin.trim() && !!ownerPhone.trim()
);
if (active === 3) return category === 'Inland Waterway Vessel'
? !!files.vesselPhotos
: !!files.vesselPhotos && !!files.proofOfOwnership && !!files.shipParticulars && !!files.insuranceCertificate;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-registrations',
method: 'POST',
body: {
category, vesselName, vesselType, capacityLabel, capacityValue, vesselLengthM,
flagState, portOfRegistry, imoOrHullNumber, manufacturerShipyard, yearBuilt,
engineType, enginePowerKw, numberOfEngines, hullMaterial,
ownerName, ownerNationalIdOrTin, ownerPhone, ownerAddress,
},
}).unwrap();
notify.success('Vessel registration submitted successfully!');
navigate('/vessel-registration');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Vessel Registration Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<StepIndicator active={active} completed={completed} />
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 0: Vessel Category ──────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Text fz="sm" c="dimmed">Select the primary use category of the vessel to be registered.</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{[
{
value: 'Inland Waterway Vessel',
icon: IconWaveSine,
title: 'Inland Waterway Vessel',
desc: 'Vessels operating on lakes, rivers, and inland waterways within Ethiopia (e.g. Lake Tana, Hawassa, Blue Nile)',
},
{
value: 'Sea-going Vessel (International)',
icon: IconShip,
title: 'Sea-going Vessel (International)',
desc: 'Vessels operating in international waters, Red Sea, Gulf of Aden, and ocean routes',
},
].map((opt) => {
const Icon = opt.icon;
const selected = category === opt.value;
return (
<Card
key={opt.value}
withBorder
radius="md"
p="lg"
style={{
cursor: 'pointer',
borderColor: selected ? 'var(--mantine-color-blue-5)' : 'var(--mantine-color-default-border)',
borderWidth: selected ? 2 : 1,
background: selected ? 'var(--mantine-color-blue-light)' : undefined,
transition: 'all 0.15s ease',
}}
onClick={() => { setCategory(opt.value); next(); }}
>
<ThemeIcon size={48} radius="md" color="blue" variant={selected ? 'filled' : 'light'} mb="sm">
<Icon size={26} />
</ThemeIcon>
<Text fw={700} fz="md" mb={4}>{opt.title}</Text>
<Text fz="sm" c="dimmed">{opt.desc}</Text>
</Card>
);
})}
</SimpleGrid>
</Stack>
)}
{/* ── Step 1: Vessel Details ───────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Category: <strong>{category}</strong>
</Alert>
<SectionHead title="Vessel Identification" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Vessel Name"
placeholder="e.g. Lake Tana Star"
required
value={vesselName}
onChange={(e) => setVesselName(e.currentTarget.value)}
/>
<Select
label="Vessel Type"
placeholder="Select vessel type"
required
data={vesselTypeOptions}
value={vesselType}
onChange={setVesselType}
/>
<NumberInput
label={capacityLabel}
placeholder="Enter value"
required
min={1}
value={capacityValue}
onChange={setCapacityValue}
/>
<NumberInput
label="Vessel Length (meters)"
placeholder="e.g. 32"
required
min={1}
value={vesselLengthM}
onChange={setVesselLengthM}
/>
<TextInput
label="Flag State"
required
value={flagState}
onChange={(e) => setFlagState(e.currentTarget.value)}
/>
<TextInput
label="Registration Area"
placeholder="e.g. Bahir Dar"
required
value={portOfRegistry}
onChange={(e) => setPortOfRegistry(e.currentTarget.value)}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 2: Technical & Ownership ───────────────────────── */}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Technical Specifications" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label={idLabel}
placeholder={category === 'Sea-going Vessel (International)' ? 'IMO0000000' : 'ETH-INL-0000'}
required
value={imoOrHullNumber}
onChange={(e) => setImoOrHullNumber(e.currentTarget.value)}
/>
<TextInput
label="Manufacturer / Shipyard Name"
placeholder="e.g. Hyundai Heavy Industries"
required
value={manufacturerShipyard}
onChange={(e) => setManufacturerShipyard(e.currentTarget.value)}
/>
<NumberInput
label="Year Built"
placeholder="e.g. 2019"
required
min={1900}
max={new Date().getFullYear()}
value={yearBuilt}
onChange={setYearBuilt}
/>
<Select
label="Engine Type"
placeholder="Select engine type"
required
data={ENGINE_TYPES}
value={engineType}
onChange={setEngineType}
/>
<NumberInput
label="Engine Power (kW)"
placeholder="e.g. 450"
required
min={1}
value={enginePowerKw}
onChange={setEnginePowerKw}
/>
<NumberInput
label="Number of Engines"
placeholder="e.g. 2"
required
min={1}
max={12}
value={numberOfEngines}
onChange={setNumberOfEngines}
/>
<Select
label="Hull Material"
placeholder="Select material"
required
data={HULL_MATERIALS}
value={hullMaterial}
onChange={setHullMaterial}
/>
</SimpleGrid>
<SectionHead title="Owner Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Owner Name / Company"
placeholder="e.g. Abebe Girma"
required
value={ownerName}
onChange={(e) => setOwnerName(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="e.g. ET-9812345"
required
value={ownerNationalIdOrTin}
onChange={(e) => setOwnerNationalIdOrTin(e.currentTarget.value)}
/>
<TextInput
label="Owner Phone"
placeholder="+251 9XX XXX XXX"
required
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.currentTarget.value)}
/>
<TextInput
label="Owner Address"
placeholder="City, Region"
value={ownerAddress}
onChange={(e) => setOwnerAddress(e.currentTarget.value)}
style={{ gridColumn: 'span 2' }}
/>
</SimpleGrid>
</Stack>
)}
{/* ── Step 3: Documents Upload ─────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{docSlots.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{/* ── Step 4: Review & Submit ──────────────────────────────── */}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. You will be notified by the authority on application status.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Category" value={category ?? ''} />
<ReviewRow label="Vessel Name" value={vesselName} />
<ReviewRow label="Vessel Type" value={vesselType ?? ''} />
<ReviewRow label={capacityLabel} value={String(capacityValue)} />
<ReviewRow label="Vessel Length (m)" value={String(vesselLengthM)} />
<ReviewRow label="Flag State" value={flagState} />
<ReviewRow label="Registration Area" value={portOfRegistry} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label={idLabel} value={imoOrHullNumber} />
<ReviewRow label="Manufacturer / Shipyard" value={manufacturerShipyard} />
<ReviewRow label="Year Built" value={String(yearBuilt)} />
<ReviewRow label="Engine Type" value={engineType ?? ''} />
<ReviewRow label="Engine Power (kW)" value={String(enginePowerKw)} />
<ReviewRow label="Number of Engines" value={String(numberOfEngines)} />
<ReviewRow label="Hull Material" value={hullMaterial ?? ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Owner Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Owner Name" value={ownerName} />
<ReviewRow label="National ID / TIN" value={ownerNationalIdOrTin} />
<ReviewRow label="Phone" value={ownerPhone} />
<ReviewRow label="Address" value={ownerAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{docSlots.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={active === 0 ? () => navigate('/vessel-registration') : prev}
>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
disabled={!canNext()}
onClick={next}
>
Next
</Button>
) : (
<Button
color="teal"
leftSection={<IconAnchor size={16} />}
loading={submitting}
onClick={handleSubmit}
>
Submit Registration
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,339 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconAlertCircle,
IconFileDescription,
IconShieldCheck,
IconCertificate,
IconDownload,
IconInfoCircle,
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
interface VesselRegistration {
id: string;
vesselName: string;
category: VesselCategory;
vesselType: string;
flagState: string;
portOfRegistry: string;
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
capacityValue: number;
vesselLengthM: number;
imoOrHullNumber: string;
ownerName: string;
status: VesselRegStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
renewalStatus: RenewalStatus;
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// Inland vessel certificates (1)
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
// Sea-going vessel certificates (4)
const SEAGOING_CERTIFICATES = [
{ label: 'Certificate of Nationality', description: 'Certifies the vessel\'s nationality and right to fly the Ethiopian flag' },
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
];
// ---------------------------------------------------------------------------
// Requirements list
// ---------------------------------------------------------------------------
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light">
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
// ---------------------------------------------------------------------------
// Certificate card (shown after approval)
// ---------------------------------------------------------------------------
function CertificateCard({ label, description }: { label: string; description: string }) {
return (
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={600} fz="sm">{label}</Text>
<Text fz="xs" c="dimmed">{description}</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function VesselRegistrationPage() {
const navigate = useNavigate();
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setRegistration(data))
.catch(() => {/* no registration yet */});
}, [fetchTrigger]);
const certs = registration?.category === 'Sea-going Vessel (International)'
? SEAGOING_CERTIFICATES
: INLAND_CERTIFICATES;
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration</Title>
<Text fz="sm" c="dimmed">Register your vessel with the Ethiopian Maritime Authority</Text>
</div>
</Group>
{/* ── No registration yet ───────────────────────────────────────── */}
{!registration && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light">
<IconAnchor size={28} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">Register Your Vessel</Text>
<Text fz="sm" c="dimmed">
Obtain official registration for inland waterway or sea-going vessels
</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Proof of Ownership / Bill of Sale" />
<RequirementItem label="Builder's Certificate or Technical Specifications" />
<RequirementItem label="Valid Insurance Certificate (Hull & Machinery)" />
<RequirementItem label="Tax Clearance Certificate" />
<RequirementItem label="Vessel Photos (at least 2 clear images)" />
<RequirementItem label="IMO Certificate of Registry (sea-going re-registration only)" />
</Stack>
<Button
size="md"
leftSection={<IconAnchor size={18} />}
onClick={() => navigate('/vessel-registration/apply')}
>
Start Vessel Registration
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Vessel Registration</Text>
</Group>
<Text fz="sm" c="dimmed">
Registration is valid for <strong>5 years</strong> from the date of approval. After approval,
inland vessels receive an <strong>Inland Vessel Registration Certificate</strong>, while
sea-going vessels receive four certificates: Certificate of Nationality, Certificate of
Ownership, Certificate of Registration, and Minimum Safe Manning Certificate.
</Text>
</Paper>
</>
)}
{/* ── Registration exists ──────────────────────────────────────── */}
{registration && (
<>
{/* Renewal alert */}
{registration.renewalStatus === 'Due Soon' && (
<Alert
icon={<IconAlertCircle size={17} />}
color="orange"
title="Renewal Due Soon"
>
Your vessel registration expires on {registration.expiryDate}. Please initiate renewal to avoid expiry.
<Button size="xs" variant="white" color="orange" mt="xs">
Start Renewal
</Button>
</Alert>
)}
{registration.renewalStatus === 'Overdue' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Registration Expired">
Your vessel registration expired on {registration.expiryDate}. Immediate renewal is required.
</Alert>
)}
{/* Status card */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light">
<IconAnchor size={22} />
</ThemeIcon>
<div>
<Text fw={700} fz="lg">{registration.vesselName}</Text>
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[
{ label: 'Category', value: registration.category },
{ label: 'Vessel Type', value: registration.vesselType },
{ label: 'Flag State', value: registration.flagState },
{ label: 'Port of Registry', value: registration.portOfRegistry },
{ label: registration.capacityLabel, value: String(registration.capacityValue) },
{ label: 'Submitted', value: registration.submittedDate },
].map((row) => (
<div key={row.label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{row.label}</Text>
<Text fz="sm" mt={2}>{row.value || '—'}</Text>
</div>
))}
</SimpleGrid>
{registration.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{registration.remarks}</Text>
</>
)}
</Paper>
{/* Timeline / status info */}
{registration.status !== 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Review', done: registration.status !== 'Pending' },
{ label: 'Approved', done: registration.status === 'Approved' },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{/* Transfer ownership — only when approved */}
{registration.status === 'Approved' && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<div>
<Text fw={600} fz="sm">Transfer Ownership</Text>
<Text fz="xs" c="dimmed">Transfer this vessel to a new owner</Text>
</div>
<Button
leftSection={<IconTransferIn size={15} />}
color="violet"
variant="light"
size="sm"
onClick={() => navigate('/vessel-registration/transfer')}
>
Request Transfer
</Button>
</Group>
</Paper>
)}
{/* Certificates section — shown after approval */}
{registration.status === 'Approved' && (
<div>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">
{registration.category === 'Sea-going Vessel (International)'
? 'Issued Certificates (4)'
: 'Issued Certificate'}
</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your vessel registration has been approved. You may download your certificate(s) below.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{certs.map((cert) => (
<CertificateCard key={cert.label} label={cert.label} description={cert.description} />
))}
</SimpleGrid>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -34,6 +34,17 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
// Phase 3 — Endorsement
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
// Vessel Registration
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
import { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
// Vessel Owner Portal (restricted)
import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
export const router = createBrowserRouter([
// Public auth pages
@@ -90,6 +101,14 @@ export const router = createBrowserRouter([
// Phase 3 — Endorsement
{ path: '/endorsements', element: <EndorsementPage /> },
// Vessel Registration
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
// Vessel Owner Portal — public login/register
{ path: '/vessel-owner/login', element: <VesselOwnerLoginPage /> },
{ path: '/vessel-owner/register', element: <VesselOwnerRegisterPage /> },
// General
{ path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },