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,415 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconFileDescription,
IconSearch,
IconTransferIn,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data (self-contained — portal page has its own copy)
// ---------------------------------------------------------------------------
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 / Purchase',
remarks: 'Vessel sold to new owner. Bill of sale attached.',
status: 'Pending',
submittedDate: '2024-06-01',
approvalDate: null,
},
{
id: 'OT-2024-002',
vesselId: 'VR-2024-002',
vesselName: 'Red Sea Voyager',
category: 'Sea-going Vessel (International)',
vesselType: 'General Cargo',
currentOwnerName: 'Ethio Shipping Lines PLC',
currentOwnerIdOrTin: 'TIN-0045678',
currentOwnerPhone: '+251 115 501 010',
newOwnerName: 'Ethiopian Maritime Transport S.C.',
newOwnerIdOrTin: 'TIN-0078910',
newOwnerPhone: '+251 115 502 020',
newOwnerEmail: 'info@emtsc.et',
newOwnerAddress: 'Addis Ababa, Bole Sub-city',
transferReason: 'Corporate Restructuring',
remarks: 'Merger-related transfer. Court order attached.',
status: 'Under Review',
submittedDate: '2024-05-20',
approvalDate: null,
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
const STATUS_OPTIONS = [
{ value: '', label: 'All Statuses' },
{ value: 'Pending', label: 'Pending' },
{ value: 'Under Review', label: 'Under Review' },
{ value: 'Approved', label: 'Approved' },
{ value: 'Rejected', label: 'Rejected' },
];
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
// Certificates generated on approval
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
const SEAGOING_CERTS = [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
];
export function VesselOwnershipTransferQueuePage() {
const navigate = useNavigate();
const [records, setRecords] = useState<OwnershipTransferRequest[]>([]);
const [filtered, setFiltered] = useState<OwnershipTransferRequest[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selected, setSelected] = useState<OwnershipTransferRequest | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState<string>('');
const [remarks, setRemarks] = useState('');
const [saving, setSaving] = useState(false);
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest[]>();
const [actionTrigger] = useApiMutation<{ success: boolean }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-ownership-transfers', method: 'GET' })
.unwrap()
.then((data) => setRecords(Array.isArray(data) ? data : [data]))
.catch(() => setRecords(MOCK_TRANSFER_REQUESTS));
}, [fetchTrigger]);
useEffect(() => {
let result = records;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((r) =>
r.vesselName.toLowerCase().includes(q) ||
r.id.toLowerCase().includes(q) ||
r.currentOwnerName.toLowerCase().includes(q) ||
r.newOwnerName.toLowerCase().includes(q)
);
}
if (statusFilter) result = result.filter((r) => r.status === statusFilter);
setFiltered(result);
}, [records, search, statusFilter]);
const openDrawer = (req: OwnershipTransferRequest) => { setSelected(req); setDrawerOpen(true); };
const closeDrawer = () => { setDrawerOpen(false); setSelected(null); };
const handleOpenModal = () => {
if (!selected) return;
setNewStatus(selected.status);
setRemarks(selected.remarks ?? '');
setModalOpen(true);
};
const handleAction = async () => {
if (!selected) return;
setSaving(true);
const isApproval = newStatus === 'Approved';
try {
await actionTrigger({
url: `/vessel-ownership-transfers/${selected.id}/status`,
method: 'PATCH',
body: { status: newStatus, remarks, generateCertificates: isApproval },
}).unwrap();
} catch { /* mock mode */ }
const today = new Date().toISOString().split('T')[0];
setRecords((prev) =>
prev.map((r) =>
r.id === selected.id
? { ...r, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : r.approvalDate }
: r
)
);
setSelected((prev) => prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : null);
setModalOpen(false);
setSaving(false);
if (isApproval) {
const certs = selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
notify.success(`Ownership transfer approved. ${certs.length} certificate(s) generated for ${selected.newOwnerName}.`);
} else {
notify.success('Status updated.');
}
};
// Stats
const total = records.length;
const pending = records.filter((r) => r.status === 'Pending').length;
const underReview = records.filter((r) => r.status === 'Under Review').length;
const approved = records.filter((r) => r.status === 'Approved').length;
const isTerminal = selected?.status === 'Approved' || selected?.status === 'Rejected';
return (
<Stack gap="md">
{/* Header */}
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer Queue</Title>
<Text fz="sm" c="dimmed">Review and process vessel ownership transfer requests</Text>
</div>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Requests', value: total, color: 'blue' },
{ label: 'Pending', value: pending, color: 'gray' },
{ label: 'Under Review', value: underReview, color: 'yellow' },
{ label: 'Approved', value: approved, color: 'teal' },
].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>
{/* Filters */}
<Group gap="sm">
<TextInput
placeholder="Search vessel, owner..."
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All Statuses"
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? '')}
clearable
w={160}
/>
</Group>
{/* Table */}
<Paper withBorder radius="md" style={{ overflow: 'hidden' }}>
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Request ID</Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={8}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No transfer requests found.</Text>
</Table.Td>
</Table.Tr>
) : filtered.map((req) => (
<Table.Tr key={req.id}>
<Table.Td><Text fz="sm" fw={600} c="blue.6">{req.id}</Text></Table.Td>
<Table.Td>
<Text fz="sm" fw={600}>{req.vesselName}</Text>
<Text fz="xs" c="dimmed">{req.category}</Text>
</Table.Td>
<Table.Td><Text fz="sm">{req.currentOwnerName}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.newOwnerName}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.transferReason}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => openDrawer(req)}>Review</Button>
<Button size="xs" variant="subtle" onClick={() => navigate(`/vessel-ownership-transfer/${req.id}`)}>
Details
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
{/* Drawer */}
<Drawer
opened={drawerOpen}
onClose={closeDrawer}
position="right"
size="md"
title={<Text fw={700}>Transfer Request {selected?.id}</Text>}
>
{selected && (
<Stack gap="md">
<Paper withBorder radius="sm" p="sm">
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={6}>Vessel</Text>
<Text fw={600}>{selected.vesselName}</Text>
<Text fz="sm" c="dimmed">{selected.category} · {selected.vesselType}</Text>
</Paper>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'From (Owner)', value: selected.currentOwnerName },
{ label: 'From (ID/TIN)', value: selected.currentOwnerIdOrTin },
{ label: 'To (New Owner)', value: selected.newOwnerName },
{ label: 'To (ID/TIN)', value: selected.newOwnerIdOrTin },
{ label: 'New Owner Phone', value: selected.newOwnerPhone },
{ label: 'New Owner Email', value: selected.newOwnerEmail || '—' },
{ label: 'New Owner Address', value: selected.newOwnerAddress || '—' },
{ label: 'Transfer Reason', value: selected.transferReason },
{ label: 'Submitted', value: selected.submittedDate },
{ label: 'Current Status', value: selected.status },
].map((row) => (
<div key={row.label}>
<Text fz="xs" c="dimmed">{row.label}</Text>
<Text fz="sm" fw={500}>{row.value}</Text>
</div>
))}
</SimpleGrid>
{selected.remarks && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
<Text fz="xs" c="dimmed" mb={2}>Remarks</Text>
<Text fz="sm">{selected.remarks}</Text>
</Paper>
)}
<Divider />
{/* Certificates preview */}
{selected.status === 'Approved' && (
<Paper withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
<Group gap="xs" mb={6}>
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" fw={700} c="teal.7">Certificates Generated for {selected.newOwnerName}</Text>
</Group>
{(selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS).map((cert) => (
<Text key={cert} fz="xs" c="teal.7"> {cert}</Text>
))}
</Paper>
)}
{!isTerminal && (
<Button color="violet" fullWidth onClick={handleOpenModal}>
Update Status
</Button>
)}
<Button variant="subtle" fullWidth onClick={() => navigate(`/vessel-ownership-transfer/${selected.id}`)}>
View Full Details
</Button>
</Stack>
)}
</Drawer>
{/* Status modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
<Stack gap="md">
<Select
label="New Status"
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
value={newStatus}
onChange={(v) => setNewStatus(v ?? '')}
/>
{newStatus === 'Approved' && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-teal-light)">
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7">
Approving will transfer ownership to <strong>{selected?.newOwnerName}</strong> and auto-generate{' '}
{selected?.category === 'Inland Waterway Vessel' ? '1 certificate' : '4 certificates'}.
</Text>
</Group>
</Paper>
)}
<Textarea label="Remarks" placeholder="Add any remarks or notes..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'} loading={saving} disabled={!newStatus} onClick={handleAction}>
{newStatus === 'Approved' ? 'Approve & Transfer' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,334 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconShieldCheck,
IconTransferIn,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_TRANSFER_REQUESTS } from './VesselOwnershipTransferQueuePage';
import type { OwnershipTransferRequest, TransferStatus } from './VesselOwnershipTransferQueuePage';
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
const SEAGOING_CERTS = [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
];
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
return (
<div>
<Text fz="xs" c="dimmed">{label}</Text>
<Text fz="sm" fw={500}>{value ?? '—'}</Text>
</div>
);
}
export function VesselOwnershipTransferReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [record, setRecord] = useState<OwnershipTransferRequest | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState('');
const [remarks, setRemarks] = useState('');
const [saving, setSaving] = useState(false);
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest>();
const [actionTrigger] = useApiMutation<{ success: boolean }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/vessel-ownership-transfers/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setRecord(data); setLoading(false); })
.catch(() => {
setRecord(MOCK_TRANSFER_REQUESTS.find((r) => r.id === id) ?? null);
setLoading(false);
});
}, [fetchTrigger, id]);
const handleOpenModal = () => {
if (!record) return;
setNewStatus(record.status);
setRemarks(record.remarks ?? '');
setModalOpen(true);
};
const handleAction = async () => {
if (!record) return;
setSaving(true);
const isApproval = newStatus === 'Approved';
try {
await actionTrigger({
url: `/vessel-ownership-transfers/${record.id}/status`,
method: 'PATCH',
body: { status: newStatus, remarks, generateCertificates: isApproval },
}).unwrap();
} catch { /* mock */ }
const today = new Date().toISOString().split('T')[0];
setRecord((prev) =>
prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : prev
);
setModalOpen(false);
setSaving(false);
if (isApproval) {
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
notify.success(`Transfer approved. ${certs.length} certificate(s) generated for ${record.newOwnerName}.`);
} else {
notify.success('Status updated.');
}
};
if (loading) return <Text fz="sm" c="dimmed" p="xl">Loading...</Text>;
if (!record) return (
<Stack p="xl" align="center">
<Text c="dimmed">Transfer request not found.</Text>
<Button variant="subtle" onClick={() => navigate('/vessel-ownership-transfer')}>Back to Queue</Button>
</Stack>
);
const isTerminal = record.status === 'Approved' || record.status === 'Rejected';
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
return (
<Stack gap="md">
{/* Header */}
<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 Review</Title>
<Text fz="sm" c="dimmed">{record.id} · {record.vesselName}</Text>
</div>
</Group>
<Group gap="sm">
<Badge size="lg" color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
<Button leftSection={<IconArrowLeft size={15} />} variant="default" size="sm" onClick={() => navigate('/vessel-ownership-transfer')}>
Back to Queue
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" style={{ alignItems: 'start' }}>
{/* Left column */}
<Stack gap="md">
{/* Current owner */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Current Owner</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Full Name" value={record.currentOwnerName} />
<InfoRow label="National ID / TIN" value={record.currentOwnerIdOrTin} />
<InfoRow label="Phone" value={record.currentOwnerPhone} />
</SimpleGrid>
</Paper>
{/* New owner */}
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-violet-3)' }}>
<Text fw={700} fz="sm" mb="sm" c="violet.7">New Owner</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Full Name" value={record.newOwnerName} />
<InfoRow label="National ID / TIN" value={record.newOwnerIdOrTin} />
<InfoRow label="Phone" value={record.newOwnerPhone} />
<InfoRow label="Email" value={record.newOwnerEmail} />
<InfoRow label="Address" value={record.newOwnerAddress} />
</SimpleGrid>
</Paper>
{/* Transfer info */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Transfer Details</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Vessel Name" value={record.vesselName} />
<InfoRow label="Vessel Category" value={record.category} />
<InfoRow label="Vessel Type" value={record.vesselType} />
<InfoRow label="Transfer Reason" value={record.transferReason} />
<InfoRow label="Submitted Date" value={record.submittedDate} />
<InfoRow label="Approval Date" value={record.approvalDate ?? 'Not yet approved'} />
</SimpleGrid>
</Paper>
</Stack>
{/* Right column */}
<Stack gap="md">
{/* Document */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Supporting Documents</Text>
<Divider mb="sm" />
<Card withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<ThemeIcon size={34} radius="sm" color="violet" variant="light">
<IconFileDescription size={18} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>Bill of Sale / Transfer Document</Text>
<Text fz="xs" c="dimmed">Legal transfer document</Text>
</div>
</Group>
<Group gap={6}>
<Button size="xs" variant="light" color="blue">View</Button>
<Button size="xs" variant="subtle" leftSection={<IconDownload size={13} />}>Download</Button>
</Group>
</Group>
</Card>
</Paper>
{/* Remarks */}
{record.remarks && (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks / Notes</Text>
<Divider mb="sm" />
<Text fz="sm">{record.remarks}</Text>
</Paper>
)}
{/* Timeline */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Timeline</Text>
<Divider mb="sm" />
<Stack gap="xs">
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="blue" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Submitted {record.submittedDate}</Text>
</Group>
{record.status !== 'Pending' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="yellow" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Under Review</Text>
</Group>
)}
{record.status === 'Approved' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="teal" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Approved {record.approvalDate}</Text>
</Group>
)}
{record.status === 'Rejected' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="red" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Rejected</Text>
</Group>
)}
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{/* Certificates — shown after approval */}
{record.status === 'Approved' && (
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="sm" c="teal.7">Certificates Generated for {record.newOwnerName}</Text>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={{ base: 1, sm: 2, md: certs.length }} spacing="sm">
{certs.map((cert, i) => (
<Card key={cert} withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
<Group gap="sm" mb="xs">
<ThemeIcon size={28} radius="md" color="teal" variant="light">
<Text fz="xs" fw={800}>{i + 1}</Text>
</ThemeIcon>
<Text fz="sm" fw={600} style={{ flex: 1 }}>{cert}</Text>
</Group>
<Text fz="xs" c="dimmed" mb="xs">Issued to: {record.newOwnerName} · {record.approvalDate}</Text>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={13} />} fullWidth>
Download Certificate
</Button>
</Card>
))}
</SimpleGrid>
</Paper>
)}
{/* Action bar */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Current status: <strong>{record.status}</strong></Text>
<Button color="violet" onClick={handleOpenModal}>Update Status</Button>
</Group>
</Paper>
)}
{/* Status modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
<Stack gap="md">
<Select
label="New Status"
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
value={newStatus}
onChange={(v) => setNewStatus(v ?? '')}
/>
{newStatus === 'Approved' && (
<Alert icon={<IconCircleCheck size={14} />} color="teal" variant="light">
Approving will officially transfer ownership to <strong>{record.newOwnerName}</strong> and generate{' '}
{certs.length} certificate{certs.length > 1 ? 's' : ''}.
</Alert>
)}
<Textarea label="Remarks" placeholder="Add remarks..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'}
loading={saving}
disabled={!newStatus}
onClick={handleAction}
>
{newStatus === 'Approved' ? 'Approve & Transfer Ownership' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,527 @@
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 } 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={3}>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 />
<Group justify="flex-end">
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button color="teal" onClick={saveField}>
{isNew ? 'Add Field' : 'Save Changes'}
</Button>
</Group>
</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>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,535 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShieldCheck,
IconX,
IconAlertCircle,
IconClockHour4,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
export type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
export type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
export 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;
manufacturerShipyard: string;
yearBuilt: number;
engineType: string;
enginePowerKw: number;
numberOfEngines: number;
hullMaterial: string;
ownerName: string;
ownerNationalIdOrTin: string;
ownerPhone: string;
ownerAddress: string;
status: VesselRegStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
renewalStatus: RenewalStatus;
expiryDate: string | null;
docsComplete: boolean;
}
export const MOCK_VESSEL_REGISTRATIONS: VesselRegistration[] = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
flagState: 'Ethiopia',
portOfRegistry: 'Bahir Dar',
capacityLabel: 'Passenger Capacity',
capacityValue: 120,
vesselLengthM: 32,
imoOrHullNumber: 'ETH-INL-2024-0042',
manufacturerShipyard: 'Ethio Marine Works',
yearBuilt: 2019,
engineType: 'Diesel Engine',
enginePowerKw: 450,
numberOfEngines: 2,
hullMaterial: 'Steel',
ownerName: 'Abebe Girma',
ownerNationalIdOrTin: 'ET-9812345',
ownerPhone: '+251 911 234 567',
ownerAddress: 'Bahir Dar, Amhara Region',
status: 'Under Review',
submittedDate: '2024-03-15',
approvalDate: null,
remarks: 'Documents submitted. Under initial review by maritime officer.',
renewalStatus: 'Not Applicable',
expiryDate: null,
docsComplete: true,
},
{
id: 'VR-2024-002',
vesselName: 'Red Sea Voyager',
category: 'Sea-going Vessel (International)',
vesselType: 'General Cargo',
flagState: 'Ethiopia',
portOfRegistry: 'Djibouti (Nominated)',
capacityLabel: 'Gross Tonnage (GT)',
capacityValue: 4200,
vesselLengthM: 98,
imoOrHullNumber: 'IMO9876543',
manufacturerShipyard: 'Hyundai Heavy Industries',
yearBuilt: 2015,
engineType: 'Diesel Engine',
enginePowerKw: 8500,
numberOfEngines: 1,
hullMaterial: 'Steel',
ownerName: 'Ethio Shipping Lines PLC',
ownerNationalIdOrTin: 'TIN-0045678',
ownerPhone: '+251 115 501 010',
ownerAddress: 'Addis Ababa, Bole Sub-city',
status: 'Pending',
submittedDate: '2024-04-02',
approvalDate: null,
remarks: '',
renewalStatus: 'Not Applicable',
expiryDate: null,
docsComplete: false,
},
{
id: 'VR-2023-018',
vesselName: 'Hawassa Queen',
category: 'Inland Waterway Vessel',
vesselType: 'Water Taxi',
flagState: 'Ethiopia',
portOfRegistry: 'Hawassa',
capacityLabel: 'Passenger Capacity',
capacityValue: 24,
vesselLengthM: 12,
imoOrHullNumber: 'ETH-INL-2023-0018',
manufacturerShipyard: 'Ethio Marine Works',
yearBuilt: 2022,
engineType: 'Outboard Motor',
enginePowerKw: 90,
numberOfEngines: 2,
hullMaterial: 'Aluminum',
ownerName: 'Yohannes Desta',
ownerNationalIdOrTin: 'ET-4456789',
ownerPhone: '+251 933 112 234',
ownerAddress: 'Hawassa, Sidama Region',
status: 'Approved',
submittedDate: '2023-11-10',
approvalDate: '2023-12-05',
remarks: 'All documents verified. Registration approved.',
renewalStatus: 'Valid',
expiryDate: '2028-12-05',
docsComplete: true,
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
function computeRenewalStatus(approvalDate: string | null): RenewalStatus {
if (!approvalDate) return 'Not Applicable';
const expiry = new Date(approvalDate);
expiry.setFullYear(expiry.getFullYear() + 5);
const days = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
if (days < 0) return 'Overdue';
if (days <= 180) return 'Due Soon';
return 'Valid';
}
function RenewalBadge({ status }: { status: RenewalStatus }) {
if (status === 'Due Soon') return <Badge color="orange" size="xs">Due Soon</Badge>;
if (status === 'Overdue') return <Badge color="red" size="xs">Overdue</Badge>;
if (status === 'Valid') return <Badge color="teal" size="xs">Valid</Badge>;
return <Text fz="xs" c="dimmed"></Text>;
}
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function VesselDrawer({
reg,
opened,
onClose,
onAction,
onFullReview,
}: {
reg: VesselRegistration | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
if (!reg) return null;
const isTerminal = reg.status === 'Approved' || reg.status === 'Rejected';
const submit = (action: 'approve' | 'reject' | 'correction') => {
onAction(reg.id, action, remarks);
setRemarks('');
setConfirmModal(null);
onClose();
};
return (
<>
<Drawer opened={opened} onClose={onClose} title={`Registration ${reg.id}`} position="right" size="lg" padding="lg">
<Stack gap="md">
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(reg.id); }}>
Open Full Review Page
</Button>
{/* Vessel info */}
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Vessel Name', value: reg.vesselName },
{ label: 'Category', value: reg.category },
{ label: 'Type', value: reg.vesselType },
{ label: reg.capacityLabel, value: String(reg.capacityValue) },
{ label: 'Length (m)', value: String(reg.vesselLengthM) },
{ label: 'IMO / Hull No.', value: reg.imoOrHullNumber },
{ label: 'Owner', value: reg.ownerName },
{ label: 'Submitted', value: reg.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>
</Paper>
{/* Document checklist */}
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Vessel Photos', ok: reg.docsComplete },
{ label: 'Proof of Ownership', ok: reg.docsComplete },
{ label: "Builder's Certificate", ok: reg.docsComplete },
{ label: 'Insurance Certificate', ok: reg.docsComplete },
{ label: 'Tax Clearance Certificate', ok: reg.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Status */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light">{reg.status}</Badge>
</Group>
{reg.remarks && <Text fz="xs" c="dimmed">{reg.remarks}</Text>}
</Paper>
{/* Actions */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!reg.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('correction')}>
Request Correction
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Correction'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve registration ${reg.id} for vessel "${reg.vesselName}"?`
: confirmModal === 'reject'
? `Reject registration ${reg.id}? This cannot be undone.`
: `Request corrections for registration ${reg.id}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<VesselRegistration[]>(MOCK_VESSEL_REGISTRATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [selectedReg, setSelectedReg] = useState<VesselRegistration | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<VesselRegistration[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => {
setApps((prev) => prev.map((r) => {
if (r.id !== id) return r;
const newStatus: VesselRegStatus =
action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Correction Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : r.approvalDate;
return {
...r,
status: newStatus,
approvalDate,
remarks: remarks || r.remarks,
renewalStatus: action === 'approve' ? computeRenewalStatus(approvalDate) : r.renewalStatus,
expiryDate: action === 'approve' && approvalDate
? (() => { const d = new Date(approvalDate); d.setFullYear(d.getFullYear() + 5); return d.toISOString().split('T')[0]; })()
: r.expiryDate,
};
}));
notify.success(
action === 'approve' ? 'Registration approved.' :
action === 'reject' ? 'Registration rejected.' :
'Correction request sent.'
);
};
const filtered = apps.filter((r) => {
const q = search.toLowerCase();
const matchSearch = !q || r.vesselName.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || r.ownerName.toLowerCase().includes(q);
const matchStatus = !statusFilter || r.status === statusFilter;
const matchCat = !categoryFilter || r.category === categoryFilter;
return matchSearch && matchStatus && matchCat;
});
const stats = {
total: apps.length,
underReview: apps.filter((r) => r.status === 'Under Review').length,
approved: apps.filter((r) => r.status === 'Approved').length,
rejected: apps.filter((r) => r.status === 'Rejected').length,
};
const rows = filtered.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td>
<Text fz="sm" fw={500}>{reg.id}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.vesselName}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.vesselType}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'}>
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.ownerName}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">{reg.submittedDate}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
{reg.status}
</Badge>
</Table.Td>
<Table.Td>
<RenewalBadge status={reg.renewalStatus} />
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/vessel-registration-queue/${reg.id}`)}>
Review
</Button>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => { setSelectedReg(reg); setDrawerOpen(true); }}
>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration Queue</Title>
<Text fz="sm" c="dimmed">Review and process vessel registration applications</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'Under Review', value: stats.underReview, color: 'yellow' },
{ label: 'Approved', value: stats.approved, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
{/* Filters */}
<Group gap="sm">
<TextInput
placeholder="Search by vessel name, ID, or owner..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Pending', 'Under Review', 'Approved', 'Rejected', 'Correction Required']}
value={statusFilter}
onChange={setStatusFilter}
w={200}
/>
<Select
placeholder="All categories"
clearable
data={['Inland Waterway Vessel', 'Sea-going Vessel (International)']}
value={categoryFilter}
onChange={setCategoryFilter}
w={220}
/>
</Group>
{/* Table */}
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Renewal</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={9}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No registrations found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<VesselDrawer
reg={selectedReg}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/vessel-registration-queue/${id}`)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,235 @@
import { useMemo } from 'react';
import {
Badge,
Card,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCircleCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration } from './VesselRegistrationQueuePage';
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function VesselRegistrationReportPage() {
const data = MOCK_VESSEL_REGISTRATIONS;
const stats = useMemo(() => {
const total = data.length;
const inland = data.filter((r) => r.category === 'Inland Waterway Vessel').length;
const seagoing = data.filter((r) => r.category === 'Sea-going Vessel (International)').length;
const currentYear = new Date().getFullYear();
const approvedThisYear = data.filter(
(r) => r.status === 'Approved' && r.approvalDate?.startsWith(String(currentYear))
).length;
const statusCounts: Record<string, number> = {};
data.forEach((r) => { statusCounts[r.status] = (statusCounts[r.status] ?? 0) + 1; });
const statusDist = Object.entries(statusCounts).map(([label, count]) => ({
label,
count,
pct: total > 0 ? Math.round((count / total) * 100) : 0,
color: STATUS_COLOR[label] ?? 'gray',
}));
const typeCounts: Record<string, number> = {};
data.forEach((r) => { typeCounts[r.vesselType] = (typeCounts[r.vesselType] ?? 0) + 1; });
const typeDist = Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
.map(([label, count]) => ({
label,
count,
pct: total > 0 ? Math.round((count / total) * 100) : 0,
}));
const renewalDue = data.filter(
(r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue'
);
const recent = [...data]
.sort((a, b) => new Date(b.submittedDate).getTime() - new Date(a.submittedDate).getTime())
.slice(0, 10);
return { total, inland, seagoing, approvedThisYear, statusDist, typeDist, renewalDue, recent };
}, [data]);
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration Report</Title>
<Text fz="sm" c="dimmed">Summary of all vessel registrations and renewal status</Text>
</div>
{/* KPI Cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Registered', value: stats.total, color: 'blue', icon: IconAnchor },
{ label: 'Inland Vessels', value: stats.inland, color: 'cyan', icon: IconWaveSine },
{ label: 'Sea-going Vessels', value: stats.seagoing, color: 'indigo', icon: IconShip },
{ label: 'Approved This Year', value: stats.approvedThisYear, color: 'teal', icon: IconCircleCheck },
].map((s) => {
const Icon = s.icon;
return (
<Card key={s.label} withBorder radius="md" p="md">
<Group gap="sm" mb={4}>
<ThemeIcon size={32} radius="md" color={s.color} variant="light">
<Icon size={18} />
</ThemeIcon>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
</Group>
<Text fz="2xl" fw={700} c={`${s.color}.6`}>{s.value}</Text>
</Card>
);
})}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Status Distribution */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Status Distribution</Text>
<Stack gap="sm">
{stats.statusDist.map((s) => (
<div key={s.label}>
<Group justify="space-between" mb={4}>
<Group gap="xs">
<Badge color={s.color} size="xs" variant="light">{s.label}</Badge>
</Group>
<Text fz="xs" c="dimmed">{s.count} ({s.pct}%)</Text>
</Group>
<Progress value={s.pct} color={s.color} size="sm" radius="xl" />
</div>
))}
</Stack>
</Paper>
{/* Vessel Type Breakdown */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Vessel Type Breakdown</Text>
<Stack gap="sm">
{stats.typeDist.map((t) => (
<div key={t.label}>
<Group justify="space-between" mb={4}>
<Text fz="sm">{t.label}</Text>
<Text fz="xs" c="dimmed">{t.count} ({t.pct}%)</Text>
</Group>
<Progress value={t.pct} color="blue" size="sm" radius="xl" />
</div>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Renewal Tracking */}
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconAlertCircle size={17} color="var(--mantine-color-orange-6)" />
<Text fw={700} fz="sm">Renewal Tracking</Text>
{stats.renewalDue.length > 0 && (
<Badge color="orange" size="sm">{stats.renewalDue.length} due</Badge>
)}
</Group>
{stats.renewalDue.length === 0 ? (
<Group gap="xs">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="sm" c="dimmed">No vessel registrations due for renewal in the next 180 days.</Text>
</Group>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Expiry Date</Table.Th>
<Table.Th>Renewal Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stats.renewalDue.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
<Table.Td>
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{reg.expiryDate ?? '—'}</Text></Table.Td>
<Table.Td>
<Badge color={reg.renewalStatus === 'Overdue' ? 'red' : 'orange'} size="sm">
{reg.renewalStatus}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
{/* Recent Registrations */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Recent Registrations (last {stats.recent.length})</Text>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stats.recent.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
<Table.Td>
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{reg.vesselType}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{reg.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
{reg.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,393 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconCamera,
IconCertificate,
IconCheck,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration, VesselRegStatus } from './VesselRegistrationQueuePage';
// ---------------------------------------------------------------------------
// Certificate definitions
// ---------------------------------------------------------------------------
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
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' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function InfoRow({ 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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 5);
return d.toISOString().split('T')[0];
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function VesselRegistrationReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
const [reg, setReg] = useState<VesselRegistration | null>(null);
const [status, setStatus] = useState<VesselRegStatus>('Pending');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/vessel-registrations/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setReg(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_VESSEL_REGISTRATIONS.find((r) => r.id === id) ?? null;
setReg(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Approved' || status === 'Rejected';
const handleAction = async () => {
if (!selectedStatus || !reg) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as VesselRegStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : reg.approvalDate;
setReg((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Approved' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Registration ${newStatus.toLowerCase()}.`);
};
if (!reg) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Registration not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={180} onClick={() => navigate('/vessel-registration-queue')}>
Back to Queue
</Button>
</Stack>
);
}
const certs = reg.category === 'Sea-going Vessel (International)' ? SEAGOING_CERTIFICATES : INLAND_CERTIFICATES;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration-queue')}>
Back to Queue
</Button>
</Group>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<div>
<Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} · {reg.category}</Text>
</div>
</Group>
{/* Terminal alerts */}
{status === 'Approved' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Registration Approved">
This vessel registration was approved on {reg.approvalDate}. Expiry date: {reg.expiryDate}.
{reg.category === 'Sea-going Vessel (International)'
? ' Four certificates have been issued (Nationality, Ownership, Registration, Minimum Safe Manning).'
: ' Inland Vessel Registration Certificate has been issued.'}
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Registration Rejected">
This registration has been rejected. No further changes can be made.
</Alert>
)}
{/* Action bar */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button
size="sm"
leftSection={<IconEdit size={16} />}
onClick={() => { setSelectedStatus(null); setModalOpen(true); }}
>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Left column */}
<Stack gap="md">
{/* Vessel Information */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Vessel Name" value={reg.vesselName} />
<InfoRow label="Category" value={reg.category} />
<InfoRow label="Vessel Type" value={reg.vesselType} />
<InfoRow label={reg.capacityLabel} value={String(reg.capacityValue)} />
<InfoRow label="Length (m)" value={String(reg.vesselLengthM)} />
<InfoRow label="Flag State" value={reg.flagState} />
<InfoRow label="Port of Registry" value={reg.portOfRegistry} />
</SimpleGrid>
</Paper>
{/* Technical Details */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="IMO / Hull Number" value={reg.imoOrHullNumber} />
<InfoRow label="Manufacturer / Shipyard" value={reg.manufacturerShipyard} />
<InfoRow label="Year Built" value={String(reg.yearBuilt)} />
<InfoRow label="Engine Type" value={reg.engineType} />
<InfoRow label="Engine Power (kW)" value={String(reg.enginePowerKw)} />
<InfoRow label="Number of Engines" value={String(reg.numberOfEngines)} />
<InfoRow label="Hull Material" value={reg.hullMaterial} />
</SimpleGrid>
</Paper>
{/* Ownership */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Ownership</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Owner Name" value={reg.ownerName} />
<InfoRow label="National ID / TIN" value={reg.ownerNationalIdOrTin} />
<InfoRow label="Phone" value={reg.ownerPhone} />
<InfoRow label="Address" value={reg.ownerAddress} />
</SimpleGrid>
</Paper>
</Stack>
{/* Right column */}
<Stack gap="md">
{/* Uploaded Documents */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{(reg.category === 'Inland Waterway Vessel'
? [
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
]
: [
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', fileName: 'proof_of_ownership.pdf', icon: IconFileDescription, ok: reg.docsComplete, required: true },
{ key: 'shipParticulars', label: 'Ship Particulars', fileName: 'ship_particulars.pdf', icon: IconId, ok: reg.docsComplete, required: true },
{ key: 'insuranceCertificate', label: 'Insurance Certificate', fileName: 'insurance_cert.pdf', icon: IconShieldCheck, ok: reg.docsComplete, required: true },
]
).map((doc) => {
const DocIcon = doc.icon;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={doc.ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}
{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{doc.ok ? (
<Text fz="xs" c="dimmed">{doc.fileName}</Text>
) : (
<Text fz="xs" c="red">Not uploaded</Text>
)}
</div>
</Group>
{doc.ok && (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
)}
{!doc.ok && (
<ThemeIcon size={22} radius="xl" color="red" variant="light">
<IconX size={13} />
</ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
{/* Remarks */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={reg.remarks ? undefined : 'dimmed'}>{reg.remarks || 'No remarks.'}</Text>
</Paper>
{/* Submission info */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={reg.submittedDate} />
<InfoRow label="Approval Date" value={reg.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (5 years)" value={reg.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{/* ── Issued Certificates — full width, shown after approval ── */}
{status === 'Approved' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">
{reg.category === 'Sea-going Vessel (International)'
? 'Issued Certificates — Sea-going Vessel (4 Certificates)'
: 'Issued Certificate — Inland Vessel'}
</Text>
<Text fz="xs" c="dimmed">
Approved on {reg.approvalDate} · Valid until {reg.expiryDate} (5 years)
</Text>
</div>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{certs.map((cert, idx) => (
<Card key={cert.label} withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled">
<IconCertificate size={16} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{cert.label}</Text>
<Text fz="xs" c="dimmed">{cert.description}</Text>
</div>
</Group>
<Badge color="teal" size="xs" variant="filled">#{idx + 1}</Badge>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>
Preview
</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>
Download
</Button>
</Group>
</Card>
))}
</SimpleGrid>
</Paper>
)}
{/* Status update modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Registration Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Approved', 'Rejected', 'Correction Required']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the vessel owner..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -7,10 +7,12 @@ import { logout } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import {
IconAnchor,
IconBook2,
IconChartBar,
IconCreditCard,
IconFileDescription,
IconFilePlus,
IconHeart,
IconLayoutDashboard,
IconShieldCheck,
@@ -28,21 +30,25 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/applications', label: 'nav.applications', icon: IconFileDescription },
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
{ to: '/profile', label: 'nav.profile', icon: IconUser },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
{ to: '/vessel-registration-queue', label: 'Vessel Registration Queue', icon: IconAnchor },
{ to: '/vessel-registration-queue/new', label: 'Vessel Form Builder', icon: IconFilePlus },
{ to: '/vessel-registration-report', label: 'Vessel Registration Report', icon: IconChartBar },
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer Queue', icon: IconFileDescription },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/applications', label: 'nav.applications', icon: IconFileDescription },
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
{ to: '/profile', label: 'nav.profile', icon: IconUser },
];
const HEADER_HEIGHT = 116;

View File

@@ -30,6 +30,12 @@ import { QuestionPage } from '../features/question/pages/QuestionPage';
import { ExamPage } from '../features/exam/pages/ExamPage';
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
import { ResultPage } from '../features/result/pages/ResultPage';
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
const router = createBrowserRouter([
{
@@ -68,6 +74,12 @@ const router = createBrowserRouter([
{ path: 'exams', element: <ExamPage /> },
{ path: 'exams/:id', element: <ExamDetailPage /> },
{ path: 'exam-results', element: <ResultPage /> },
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
{ path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
],
},
],

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 /> },