mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- Added VesselTransferReviewPage for reviewing and managing vessel transfer requests in backoffice. - Created VesselTransferApplicationPage for submitting new ownership transfer requests in portal. - Introduced mock data for vessels and transfer requests in both portal and backoffice. - Updated navigation and layout to include vessel transfer sections in both applications. - Added internationalization support for vessel transfer labels in English and Amharic. - Documented the implementation details and suggested API surface for future integration.
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Drawer,
|
|
Group,
|
|
Paper,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAnchor,
|
|
IconCircleCheck,
|
|
IconClock,
|
|
IconEye,
|
|
IconSearch,
|
|
IconShip,
|
|
IconX,
|
|
} from '@tabler/icons-react';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types & mock data
|
|
// ---------------------------------------------------------------------------
|
|
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
|
|
|
|
export const STATUS_COLOR: Record<TransferStatus, string> = {
|
|
Pending: 'gray',
|
|
'Under Review': 'blue',
|
|
Approved: 'teal',
|
|
Rejected: 'red',
|
|
};
|
|
|
|
export interface TransferParty {
|
|
name: string;
|
|
idOrTin: string;
|
|
phone: string;
|
|
email: string;
|
|
address: string;
|
|
}
|
|
|
|
export interface TransferRequest {
|
|
id: string;
|
|
vesselName: string;
|
|
vesselRegNo: string;
|
|
currentOwner: TransferParty;
|
|
newOwner: TransferParty;
|
|
reason: string;
|
|
status: TransferStatus;
|
|
submitted: string;
|
|
remarks?: string;
|
|
approvedOn?: string;
|
|
document: { fileName: string; fileType: 'pdf' | 'image' };
|
|
}
|
|
|
|
export const MOCK_REQUESTS: TransferRequest[] = [
|
|
{
|
|
id: 'VT-2025-0001',
|
|
vesselName: 'MV Abay',
|
|
vesselRegNo: 'ET-VSL-2021-014',
|
|
currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' },
|
|
newOwner: { name: 'Bahir Dar Shipping PLC', idOrTin: 'TIN-0044556677', phone: '+251 58 220 1234', email: 'info@bdshipping.com', address: 'Bahir Dar, Ethiopia' },
|
|
reason: 'Corporate Restructuring',
|
|
status: 'Under Review',
|
|
submitted: '2025-06-30',
|
|
document: { fileName: 'bill_of_sale_abay.pdf', fileType: 'pdf' },
|
|
},
|
|
{
|
|
id: 'VT-2025-0002',
|
|
vesselName: 'MV Tana',
|
|
vesselRegNo: 'ET-VSL-2022-031',
|
|
currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' },
|
|
newOwner: { name: 'Hana Girma', idOrTin: 'ETH-ID-0098765', phone: '+251 922 345 678', email: 'hana.girma@email.com', address: 'Piazza, Addis Ababa' },
|
|
reason: 'Sale',
|
|
status: 'Approved',
|
|
submitted: '2025-05-12',
|
|
approvedOn: '2025-05-28',
|
|
document: { fileName: 'bill_of_sale_tana.pdf', fileType: 'pdf' },
|
|
},
|
|
{
|
|
id: 'VT-2025-0003',
|
|
vesselName: 'MV Abay',
|
|
vesselRegNo: 'ET-VSL-2021-014',
|
|
currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' },
|
|
newOwner: { name: 'Dawit Alemu', idOrTin: 'ETH-ID-0011223', phone: '+251 933 456 789', email: 'dawit.alemu@email.com', address: 'Mekelle, Ethiopia' },
|
|
reason: 'Gift',
|
|
status: 'Rejected',
|
|
submitted: '2025-04-02',
|
|
remarks: 'Bill of Sale document illegible — please resubmit a clearer scan.',
|
|
document: { fileName: 'transfer_doc_scan.jpg', fileType: 'image' },
|
|
},
|
|
{
|
|
id: 'VT-2025-0004',
|
|
vesselName: 'MV Zeway',
|
|
vesselRegNo: 'ET-VSL-2020-007',
|
|
currentOwner: { name: 'Meskerem Assefa', idOrTin: 'ETH-ID-0055667', phone: '+251 944 567 890', email: 'meskerem.a@email.com', address: 'Hawassa, Ethiopia' },
|
|
newOwner: { name: 'Yonas Tesfaye', idOrTin: 'ETH-ID-0077889', phone: '+251 955 678 901', email: 'yonas.t@email.com', address: 'Adama, Ethiopia' },
|
|
reason: 'Inheritance',
|
|
status: 'Pending',
|
|
submitted: '2025-07-10',
|
|
document: { fileName: 'inheritance_transfer.pdf', fileType: 'pdf' },
|
|
},
|
|
{
|
|
id: 'VT-2025-0005',
|
|
vesselName: 'MV Awash',
|
|
vesselRegNo: 'ET-VSL-2023-009',
|
|
currentOwner: { name: 'Kidist Worku', idOrTin: 'ETH-ID-0033445', phone: '+251 966 789 012', email: 'kidist.w@email.com', address: 'Dire Dawa, Ethiopia' },
|
|
newOwner: { name: 'Nile Logistics PLC', idOrTin: 'TIN-0099887766', phone: '+251 11 550 4321', email: 'contact@nilelogistics.com', address: 'Addis Ababa, Ethiopia' },
|
|
reason: 'Court Order',
|
|
status: 'Pending',
|
|
submitted: '2025-07-15',
|
|
document: { fileName: 'court_order.pdf', fileType: 'pdf' },
|
|
},
|
|
{
|
|
id: 'VT-2025-0006',
|
|
vesselName: 'MV Genale',
|
|
vesselRegNo: 'ET-VSL-2019-002',
|
|
currentOwner: { name: 'Tesfaye Kebede', idOrTin: 'ETH-ID-0022334', phone: '+251 977 890 123', email: 'tesfaye.k@email.com', address: 'Gondar, Ethiopia' },
|
|
newOwner: { name: 'Almaz Hailu', idOrTin: 'ETH-ID-0088990', phone: '+251 988 901 234', email: 'almaz.h@email.com', address: 'Bahir Dar, Ethiopia' },
|
|
reason: 'Other',
|
|
status: 'Under Review',
|
|
submitted: '2025-07-05',
|
|
document: { fileName: 'transfer_agreement.pdf', fileType: 'pdf' },
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Quick-review drawer
|
|
// ---------------------------------------------------------------------------
|
|
function QuickReviewDrawer({
|
|
request,
|
|
opened,
|
|
onClose,
|
|
onFullReview,
|
|
}: {
|
|
request: TransferRequest | null;
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
onFullReview: (id: string) => void;
|
|
}) {
|
|
if (!request) return null;
|
|
|
|
return (
|
|
<Drawer opened={opened} onClose={onClose} title={`Transfer ${request.id}`} position="right" size="lg" padding="lg">
|
|
<Stack gap="md">
|
|
<Button
|
|
variant="light"
|
|
leftSection={<IconEye size={15} />}
|
|
fullWidth
|
|
onClick={() => { onClose(); onFullReview(request.id); }}
|
|
>
|
|
Open Full Review Page
|
|
</Button>
|
|
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group justify="space-between" mb="sm">
|
|
<Text fw={700} fz="sm">{request.vesselName}</Text>
|
|
<Badge color={STATUS_COLOR[request.status]} variant="light">{request.status}</Badge>
|
|
</Group>
|
|
<Text fz="xs" c="dimmed">{request.vesselRegNo}</Text>
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="md">
|
|
<Text fw={700} fz="sm" mb="sm">From → To</Text>
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
<Box>
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Current Owner</Text>
|
|
<Text fz="sm">{request.currentOwner.name}</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>New Owner</Text>
|
|
<Text fz="sm">{request.newOwner.name}</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Reason</Text>
|
|
<Text fz="sm">{request.reason}</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Submitted</Text>
|
|
<Text fz="sm">{request.submitted}</Text>
|
|
</Box>
|
|
</SimpleGrid>
|
|
</Paper>
|
|
|
|
{request.status === 'Rejected' && request.remarks && (
|
|
<Paper withBorder radius="md" p="md" bg="red.0">
|
|
<Text fz="xs" fw={700} c="red.7" mb={4}>Rejection Reason</Text>
|
|
<Text fz="sm">{request.remarks}</Text>
|
|
</Paper>
|
|
)}
|
|
{request.status === 'Approved' && request.approvedOn && (
|
|
<Paper withBorder radius="md" p="md" bg="teal.0">
|
|
<Text fz="sm">Transfer approved on {request.approvedOn}. New certificates issued to {request.newOwner.name}.</Text>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main page
|
|
// ---------------------------------------------------------------------------
|
|
export function VesselTransferQueuePage() {
|
|
const navigate = useNavigate();
|
|
const [requests] = useState<TransferRequest[]>(MOCK_REQUESTS);
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<TransferRequest | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
|
|
const stats = {
|
|
total: requests.length,
|
|
pending: requests.filter((r) => r.status === 'Pending').length,
|
|
underReview: requests.filter((r) => r.status === 'Under Review').length,
|
|
approved: requests.filter((r) => r.status === 'Approved').length,
|
|
};
|
|
|
|
const filtered = requests.filter((r) => {
|
|
const q = search.toLowerCase();
|
|
const matchSearch = !q
|
|
|| r.vesselName.toLowerCase().includes(q)
|
|
|| r.id.toLowerCase().includes(q)
|
|
|| r.currentOwner.name.toLowerCase().includes(q)
|
|
|| r.newOwner.name.toLowerCase().includes(q);
|
|
const matchStatus = !statusFilter || r.status === statusFilter;
|
|
return matchSearch && matchStatus;
|
|
});
|
|
|
|
const openDrawer = (req: TransferRequest) => {
|
|
setSelected(req);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<div>
|
|
<Title order={3}>Vessel Ownership Transfers</Title>
|
|
<Text fz="sm" c="dimmed">Review and process vessel ownership transfer requests</Text>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
|
{[
|
|
{ label: 'Total', value: stats.total, color: 'blue', icon: IconShip },
|
|
{ label: 'Pending', value: stats.pending, color: 'gray', icon: IconClock },
|
|
{ label: 'Under Review', value: stats.underReview, color: 'blue', icon: IconEye },
|
|
{ label: 'Approved', value: stats.approved, color: 'teal', icon: IconCircleCheck },
|
|
].map(({ label, value, color, icon: Icon }) => (
|
|
<Card key={label} withBorder radius="md" p="sm">
|
|
<Group gap="xs" wrap="nowrap">
|
|
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
|
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
|
</Group>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{/* Table */}
|
|
<Paper withBorder radius="md">
|
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
|
<Text fw={600}>Transfer Queue</Text>
|
|
<Group gap="sm" wrap="nowrap">
|
|
<TextInput
|
|
placeholder="Search by vessel, request ID or owner…"
|
|
leftSection={<IconSearch size={15} />}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
size="sm"
|
|
style={{ minWidth: rem(280) }}
|
|
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
|
/>
|
|
<Select
|
|
placeholder="All Status"
|
|
data={['Pending', 'Under Review', 'Approved', 'Rejected']}
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
clearable
|
|
size="sm"
|
|
style={{ width: rem(180) }}
|
|
/>
|
|
</Group>
|
|
</Group>
|
|
|
|
{filtered.length === 0 ? (
|
|
<Box py="xl" ta="center">
|
|
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconAnchor size={22} /></ThemeIcon>
|
|
<Text fz="sm" c="dimmed">No transfer requests found</Text>
|
|
</Box>
|
|
) : (
|
|
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
|
<Table.Thead bg="var(--mantine-color-default-hover)">
|
|
<Table.Tr>
|
|
{['Request ID', 'Vessel', 'From Owner', 'To Owner', 'Reason', 'Submitted', 'Status', ''].map((h) => (
|
|
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
|
))}
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{filtered.map((req) => (
|
|
<Table.Tr key={req.id}>
|
|
<Table.Td><Text fz="xs" fw={600} c="blue.7">{req.id}</Text></Table.Td>
|
|
<Table.Td>
|
|
<Text fz="xs" fw={500}>{req.vesselName}</Text>
|
|
<Text fz="xs" c="dimmed">{req.vesselRegNo}</Text>
|
|
</Table.Td>
|
|
<Table.Td><Text fz="xs">{req.currentOwner.name}</Text></Table.Td>
|
|
<Table.Td><Text fz="xs">{req.newOwner.name}</Text></Table.Td>
|
|
<Table.Td><Text fz="xs">{req.reason}</Text></Table.Td>
|
|
<Table.Td><Text fz="xs">{req.submitted}</Text></Table.Td>
|
|
<Table.Td><Badge color={STATUS_COLOR[req.status]} variant="light" size="xs">{req.status}</Badge></Table.Td>
|
|
<Table.Td>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Button size="xs" variant="subtle" onClick={() => openDrawer(req)}>Quick View</Button>
|
|
<Button size="xs" variant="light" leftSection={<IconEye size={13} />} onClick={() => navigate(`/vessel-transfers/${req.id}`)}>Review</Button>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
|
|
{filtered.length > 0 && (
|
|
<Group px="md" py="sm" justify="space-between">
|
|
<Text fz="xs" c="dimmed">Showing {filtered.length} of {requests.length} requests</Text>
|
|
</Group>
|
|
)}
|
|
</Paper>
|
|
|
|
<QuickReviewDrawer
|
|
request={selected}
|
|
opened={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
onFullReview={(id) => navigate(`/vessel-transfers/${id}`)}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|