mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- 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.
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|