mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: add vessel registration feature with dashboard and ownership transfer
- Implemented VesselRegistrationPage for managing vessel registrations. - Added localization for vessel registration and ownership transfer in Amharic and English. - Updated PortalLayout to include navigation for vessel registration and ownership transfer. - Created VesselOwnerLayout to restrict access to vessel owner-specific routes. - Integrated routing for vessel registration, application, and ownership transfer pages.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowRight,
|
||||
IconChartBar,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { MOCK_VESSEL_REGISTRATIONS } from '../../vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconAnchor;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<ThemeIcon size={46} radius="md" variant="light" color={color}>
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={30} fw={800} mt="md" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{label}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function VesselRegistrationHeadDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const regs = MOCK_VESSEL_REGISTRATIONS;
|
||||
|
||||
const stats = {
|
||||
total: regs.length,
|
||||
underReview: regs.filter((r) => r.status === 'Under Review' || r.status === 'Pending').length,
|
||||
approved: regs.filter((r) => r.status === 'Approved').length,
|
||||
renewalsDue: regs.filter((r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue').length,
|
||||
};
|
||||
|
||||
const recent = [...regs]
|
||||
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconAnchor size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Title order={2}>Vessel Registration Department</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Overview of vessel registration and ownership transfer activity
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconChartBar size={16} />} onClick={() => navigate('/vessel-registration-report')}>
|
||||
Full Report
|
||||
</Button>
|
||||
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Open Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<StatCard label="Total Registrations" value={stats.total} icon={IconAnchor} color="blue" />
|
||||
<StatCard label="Awaiting Review" value={stats.underReview} icon={IconClockHour4} color="yellow" />
|
||||
<StatCard label="Approved" value={stats.approved} icon={IconCircleCheck} color="teal" />
|
||||
<StatCard label="Renewals Due / Overdue" value={stats.renewalsDue} icon={IconFileDescription} color="orange" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg" align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="lg">Recent Applications</Text>
|
||||
<Anchor size="sm" fw={600} onClick={() => navigate('/vessel-registration-queue')} style={{ cursor: 'pointer' }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
View queue
|
||||
<IconArrowRight size={14} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
{recent.map((r, i) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => navigate(`/vessel-registration-queue/${r.id}`)}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{r.vesselName}</Text>
|
||||
<Text size="xs" c="dimmed">{r.id} — {r.ownerName}</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={STATUS_COLOR[r.status] ?? 'gray'} radius="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Text fw={700} fz="lg" mb="md">Quick Links</Text>
|
||||
<Stack gap="sm">
|
||||
<Button fullWidth variant="light" leftSection={<IconAnchor size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Registration Queue
|
||||
</Button>
|
||||
<Button fullWidth variant="light" leftSection={<IconTransferIn size={16} />} justify="flex-start" onClick={() => navigate('/vessel-ownership-transfer')}>
|
||||
Ownership Transfer Queue
|
||||
</Button>
|
||||
<Button fullWidth variant="light" leftSection={<IconChartBar size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-report')}>
|
||||
Registration Report
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
|
||||
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
||||
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
|
||||
@@ -17,6 +17,11 @@ export const en = {
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationQueue: 'Vessel Registration Queue',
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
vesselRegistrationReport: 'Vessel Registration Report',
|
||||
ownershipTransferQueue: 'Ownership Transfer Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
IconAnchor,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -31,6 +34,11 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ 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: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', 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 },
|
||||
|
||||
@@ -30,6 +30,13 @@ 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';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -51,6 +58,7 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
@@ -68,6 +76,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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
|
||||
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
|
||||
interface VesselRegistrationSummary {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
category: string;
|
||||
status: VesselRegStatus;
|
||||
submittedDate: string;
|
||||
renewalStatus: 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
const RENEWAL_COLOR: Record<string, string> = {
|
||||
Valid: 'teal',
|
||||
'Due Soon': 'orange',
|
||||
Overdue: 'red',
|
||||
'Not Applicable': 'gray',
|
||||
};
|
||||
|
||||
export function VesselRegistrationDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [vessels, setVessels] = useState<VesselRegistrationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistrationSummary[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setVessels(data))
|
||||
.catch(() => setVessels([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const stats = {
|
||||
total: vessels.length,
|
||||
pending: vessels.filter((v) => v.status === 'Pending' || v.status === 'Under Review' || v.status === 'Correction Required').length,
|
||||
approved: vessels.filter((v) => v.status === 'Approved').length,
|
||||
renewalsDue: vessels.filter((v) => v.renewalStatus === 'Due Soon' || v.renewalStatus === 'Overdue').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShip size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>My Vessel Registration Dashboard</Title>
|
||||
<Text fz="sm" c="dimmed">Track your vessel registrations and ownership transfers</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconTransferIn size={16} />} onClick={() => navigate('/vessel-registration/transfer')}>
|
||||
Ownership Transfer
|
||||
</Button>
|
||||
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
|
||||
Register New Vessel
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Total Vessels</Text>
|
||||
<Text fz="xl" fw={700} c="blue.6" mt={4}>{stats.total}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>In Progress</Text>
|
||||
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.pending}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Approved</Text>
|
||||
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.approved}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Renewals Due</Text>
|
||||
<Text fz="xl" fw={700} c="orange.6" mt={4}>{stats.renewalsDue}</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{!loading && 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.
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{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>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[v.status] ?? 'gray'}>{v.status}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between" mt="sm">
|
||||
<Text fz="xs" c="dimmed">Renewal</Text>
|
||||
<Badge size="xs" variant="light" color={RENEWAL_COLOR[v.renewalStatus]}>{v.renewalStatus}</Badge>
|
||||
</Group>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
vesselRegistration: 'የመርከብ ምዝገባ',
|
||||
vesselRegistrationDashboard: 'የመርከብ ምዝገባ ዳሽቦርድ',
|
||||
ownershipTransfer: 'የባለቤትነት ዝውውር',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const en = {
|
||||
dashboard: 'Dashboard',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
myApplication: 'My Application',
|
||||
vesselRegistration: 'Vessel Registration',
|
||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||
ownershipTransfer: 'Ownership Transfer',
|
||||
certificates: 'Certificates',
|
||||
endorsements: 'Endorsements',
|
||||
documents: 'My Documents',
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconArrowsExchange,
|
||||
IconBell,
|
||||
IconFolderOpen,
|
||||
IconGauge,
|
||||
IconHeadset,
|
||||
IconHome2,
|
||||
IconList,
|
||||
IconRubberStamp,
|
||||
IconSend,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -24,6 +27,9 @@ const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2 },
|
||||
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
|
||||
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
||||
{ to: '/vessel-registration-dashboard', label: 'Vessel Registration Dashboard', i18nKey: 'nav.vesselRegistrationDashboard', icon: IconGauge },
|
||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
||||
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange },
|
||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||
@@ -34,6 +40,9 @@ const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
||||
|
||||
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
||||
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
|
||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||
'/certificates': { i18nKey: 'nav.certificates' },
|
||||
|
||||
79
apps/portal/src/app/layouts/VesselOwnerLayout.tsx
Normal file
79
apps/portal/src/app/layouts/VesselOwnerLayout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconAnchor, IconHome2, IconTransferIn, IconUserCircle } from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import { logout } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
// Only vessel-registration routes are exposed to vessel owners
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/vessel-owner/dashboard', label: 'My Vessels', icon: IconHome2 },
|
||||
{ to: '/vessel-registration', label: 'Vessel Registration', icon: IconAnchor },
|
||||
{ to: '/vessel-owner/registration/transfer', label: 'Ownership Transfer', icon: IconTransferIn },
|
||||
{ to: '/vessel-owner/profile', label: 'My Profile', icon: IconUserCircle },
|
||||
];
|
||||
|
||||
export function VesselOwnerLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
|
||||
|
||||
const displayName = user?.name?.en || user?.username || 'Vessel Owner';
|
||||
const initials = displayName.split(/\s+/).map((s: string) => s[0]).join('').toUpperCase().slice(0, 2) || 'VO';
|
||||
|
||||
const go = (item: NavItem) => {
|
||||
if (item.soon) { notify.info(`${item.label} — coming soon.`); return; }
|
||||
if (item.to) { navigate(item.to); closeNav(); }
|
||||
};
|
||||
|
||||
const handleLogout = () => { dispatch(logout()); navigate('/vessel-owner/login'); };
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 74 }}
|
||||
navbar={{ width: sidebarCollapsed ? 72 : 264, breakpoint: 'sm', collapsed: { mobile: !navOpened } }}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header style={{ background: 'var(--mantine-color-body)', borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<AppHeader
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
navOpened={navOpened}
|
||||
breadcrumbs={[{ label: 'Vessel Owner Portal', path: '/vessel-owner/dashboard' }]}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
/>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p={0} style={{ overflow: 'hidden', transition: 'width 200ms ease', background: 'var(--mantine-color-body)', borderRight: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<AppSidebar
|
||||
navItems={NAV_ITEMS}
|
||||
collapsed={sidebarCollapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
onNavigate={go}
|
||||
brandName="Vessel Owner Portal"
|
||||
brandSubtitle="EMAA"
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<div key={location.pathname} className="ema-page-enter">
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,14 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
|
||||
import { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
|
||||
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
|
||||
import { VesselRegistrationDashboardPage } from './features/vessel-registration-dashboard/pages/VesselRegistrationDashboardPage';
|
||||
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
|
||||
@@ -83,6 +91,10 @@ export const router = createBrowserRouter([
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
||||
{ path: '/vessel-registration-dashboard', element: <VesselRegistrationDashboardPage /> },
|
||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
||||
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
|
||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
||||
|
||||
// General
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
@@ -90,5 +102,23 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
|
||||
{ path: '/vessel-owner/login', element: <VesselOwnerLoginPage /> },
|
||||
{ path: '/vessel-owner/register', element: <VesselOwnerRegisterPage /> },
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<VesselOwnerLayout />
|
||||
</I18nextProvider>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: '/vessel-owner/dashboard', element: <VesselOwnerDashboardPage /> },
|
||||
{ path: '/vessel-owner/registration', element: <VesselRegistrationPage /> },
|
||||
{ path: '/vessel-owner/registration/apply', element: <VesselRegistrationApplicationPage /> },
|
||||
{ path: '/vessel-owner/registration/transfer', element: <OwnershipTransferPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user