mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 10:40:59 +00:00
feat: implement vessel ownership transfer feature in portal and backoffice
- 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.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconUserQuestion,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_REQUESTS, STATUS_COLOR, type TransferParty, type TransferStatus } from './VesselTransferQueuePage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document viewer — view + download (view pattern copied from ApplicationReviewPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
// ponytail: view/download hit this demo data-URI, wire to real file storage when backend lands.
|
||||
function getDocUrl(fileName: string) {
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function DocViewer({ fileName, fileType, label }: { fileName: string; fileType: 'pdf' | 'image'; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const url = getDocUrl(fileName);
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'}>{fileType.toUpperCase()}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(220), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Group grow mt="xs">
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View
|
||||
</Button>
|
||||
<Button size="xs" variant="light" component="a" href={url} download={fileName} leftSection={<IconDownload size={13} />}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 PartyCard({ title, party }: { title: string; party: TransferParty }) {
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">{title}</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoRow label="Full Name / Company Name" value={party.name} />
|
||||
<InfoRow label="National ID / TIN" value={party.idOrTin} />
|
||||
<InfoRow label="Phone Number" value={party.phone} />
|
||||
<InfoRow label="Email Address" value={party.email} />
|
||||
<InfoRow label="Address" value={party.address} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function VesselTransferReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const record = MOCK_REQUESTS.find((r) => r.id === id);
|
||||
|
||||
const [status, setStatus] = useState<TransferStatus | undefined>(record?.status);
|
||||
const [remarks, setRemarks] = useState(record?.remarks ?? '');
|
||||
const [approvedOn, setApprovedOn] = useState(record?.approvedOn);
|
||||
|
||||
if (!record || !status) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-transfers')}><IconArrowLeft size={18} /></ActionIcon>
|
||||
<Title order={3}>Transfer Request</Title>
|
||||
</Group>
|
||||
<Alert color="red" icon={<IconInfoCircle size={16} />}>Transfer request not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const isTerminal = status === 'Approved' || status === 'Rejected';
|
||||
|
||||
const applyAction = (newStatus: TransferStatus, label: string) => {
|
||||
record.status = newStatus;
|
||||
record.remarks = remarks || record.remarks;
|
||||
if (newStatus === 'Approved') {
|
||||
const today = record.submitted; // ponytail: no live clock in mock; stamp with submitted date placeholder
|
||||
record.approvedOn = today;
|
||||
setApprovedOn(today);
|
||||
}
|
||||
setStatus(newStatus);
|
||||
notify.success(`Transfer request ${label}. SMS and email notification sent.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-transfers')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>Transfer Review — {record.vesselName}</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{record.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {record.submitted}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[status]}>{status}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar / terminal notice */}
|
||||
{!isTerminal ? (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Stack gap="sm">
|
||||
<Textarea
|
||||
label="Officer Remarks"
|
||||
placeholder="Add notes, or the reason for rejection…"
|
||||
minRows={2}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<IconUserQuestion size={15} />}
|
||||
onClick={() => applyAction('Under Review', 'marked under review')}
|
||||
disabled={status === 'Under Review'}
|
||||
>
|
||||
Mark Under Review
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => applyAction('Rejected', 'rejected')}
|
||||
disabled={!remarks.trim()}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => applyAction('Approved', 'approved')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
{!remarks.trim() && (
|
||||
<Text fz="xs" c="dimmed" ta="right">Remarks are required to reject this request.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={STATUS_COLOR[status]}
|
||||
icon={status === 'Approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
|
||||
>
|
||||
{status === 'Approved'
|
||||
? <>Transfer approved on {approvedOn}. New certificates issued to {record.newOwner.name}.</>
|
||||
: <>This request was rejected. {record.remarks}</>}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Vessel + reason */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Transfer Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
<InfoRow label="Vessel" value={`${record.vesselName} — ${record.vesselRegNo}`} />
|
||||
<InfoRow label="Transfer Reason" value={record.reason} />
|
||||
<InfoRow label="Submitted Date" value={record.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Owners */}
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<PartyCard title="Current Owner Details" party={record.currentOwner} />
|
||||
<PartyCard title="New Owner Details" party={record.newOwner} />
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Document */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Bill of Sale / Transfer Document</Text>
|
||||
<Divider mb="md" />
|
||||
<Box style={{ maxWidth: rem(420) }}>
|
||||
<DocViewer fileName={record.document.fileName} fileType={record.document.fileType} label="Transfer Document" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const am: Translations = {
|
||||
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
|
||||
cocQueue: 'የCoC/CoP ወረፋ',
|
||||
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
||||
applications: 'ማመልከቻዎች',
|
||||
paymentConfig: 'የክፍያ ውቅረት',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const en = {
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconSettings,
|
||||
IconShip,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
@@ -33,6 +34,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||
{ to: '/vessel-transfers', label: 'nav.vesselTransfers', icon: IconShip },
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
||||
|
||||
@@ -30,6 +30,8 @@ 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 { VesselTransferQueuePage } from '../features/vessel-transfer/pages/VesselTransferQueuePage';
|
||||
import { VesselTransferReviewPage } from '../features/vessel-transfer/pages/VesselTransferReviewPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -64,6 +66,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
||||
{ path: 'vessel-transfers', element: <VesselTransferQueuePage /> },
|
||||
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
|
||||
{ path: 'questions', element: <QuestionPage /> },
|
||||
{ path: 'exams', element: <ExamPage /> },
|
||||
{ path: 'exams/:id', element: <ExamDetailPage /> },
|
||||
|
||||
93
apps/portal/src/app/features/vessel-transfer/mock.ts
Normal file
93
apps/portal/src/app/features/vessel-transfer/mock.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||
|
||||
export interface Vessel {
|
||||
id: string;
|
||||
name: string;
|
||||
regNo: string;
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_VESSELS: Vessel[] = [
|
||||
{ id: 'v1', name: 'MV Abay', regNo: 'ET-VSL-2021-014', approved: true },
|
||||
{ id: 'v2', name: 'MV Tana', regNo: 'ET-VSL-2022-031', approved: true },
|
||||
{ id: 'v3', name: 'MV Awash', regNo: 'ET-VSL-2023-009', approved: false }, // pending registration — not selectable
|
||||
];
|
||||
|
||||
export const TRANSFER_REASONS = [
|
||||
'Sale',
|
||||
'Inheritance',
|
||||
'Gift',
|
||||
'Corporate Restructuring',
|
||||
'Court Order',
|
||||
'Other',
|
||||
] as const;
|
||||
|
||||
export type TransferReason = (typeof TRANSFER_REASONS)[number];
|
||||
|
||||
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 TransferRequest {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
vesselRegNo: string;
|
||||
currentOwner: string;
|
||||
newOwnerName: string;
|
||||
reason: TransferReason;
|
||||
status: TransferStatus;
|
||||
submitted: string;
|
||||
approvedOn?: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export const MOCK_REQUESTS: TransferRequest[] = [
|
||||
{
|
||||
id: 'VT-2025-0001',
|
||||
vesselName: 'MV Abay',
|
||||
vesselRegNo: 'ET-VSL-2021-014',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Bahir Dar Shipping PLC',
|
||||
reason: 'Corporate Restructuring',
|
||||
status: 'Under Review',
|
||||
submitted: '2025-06-30',
|
||||
},
|
||||
{
|
||||
id: 'VT-2025-0002',
|
||||
vesselName: 'MV Tana',
|
||||
vesselRegNo: 'ET-VSL-2022-031',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Hana Girma',
|
||||
reason: 'Sale',
|
||||
status: 'Approved',
|
||||
submitted: '2025-05-12',
|
||||
approvedOn: '2025-05-28',
|
||||
},
|
||||
{
|
||||
id: 'VT-2025-0003',
|
||||
vesselName: 'MV Abay',
|
||||
vesselRegNo: 'ET-VSL-2021-014',
|
||||
currentOwner: 'Solomon Bekele',
|
||||
newOwnerName: 'Dawit Alemu',
|
||||
reason: 'Gift',
|
||||
status: 'Rejected',
|
||||
submitted: '2025-04-02',
|
||||
rejectionReason: 'Bill of Sale document illegible — please resubmit a clearer scan.',
|
||||
},
|
||||
];
|
||||
|
||||
export function addTransferRequest(req: Omit<TransferRequest, 'id' | 'status' | 'submitted'>): TransferRequest {
|
||||
const created: TransferRequest = {
|
||||
...req,
|
||||
id: `VT-2025-${String(MOCK_REQUESTS.length + 1).padStart(4, '0')}`,
|
||||
status: 'Pending',
|
||||
submitted: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
MOCK_REQUESTS.unshift(created);
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowLeft, IconArrowRight, IconCheck, IconInfoCircle, IconUpload } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { addTransferRequest, MOCK_VESSELS, TRANSFER_REASONS } from '../mock';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function VesselTransferApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const currentOwner = user?.name?.en || user?.username || 'Current Owner';
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
// Step 0
|
||||
const [vesselId, setVesselId] = useState<string | null>(null);
|
||||
const vessel = MOCK_VESSELS.find((v) => v.id === vesselId) ?? null;
|
||||
|
||||
// Step 1 — new owner
|
||||
const [newOwnerName, setNewOwnerName] = useState('');
|
||||
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
|
||||
const [newOwnerPhone, setNewOwnerPhone] = useState('');
|
||||
const [newOwnerEmail, setNewOwnerEmail] = useState('');
|
||||
const [newOwnerAddress, setNewOwnerAddress] = useState('');
|
||||
|
||||
// Step 2 — reason + document
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const [document, setDocument] = useState<File | null>(null);
|
||||
|
||||
const step0Ok = !!vessel;
|
||||
const step1Ok = !!newOwnerName && !!newOwnerIdOrTin && !!newOwnerPhone
|
||||
&& EMAIL_RE.test(newOwnerEmail) && !!newOwnerAddress;
|
||||
const step2Ok = !!reason && !!document;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!vessel || !reason) return;
|
||||
addTransferRequest({
|
||||
vesselName: vessel.name,
|
||||
vesselRegNo: vessel.regNo,
|
||||
currentOwner,
|
||||
newOwnerName,
|
||||
reason: reason as (typeof TRANSFER_REASONS)[number],
|
||||
});
|
||||
notify.success('Transfer request submitted. SMS and email confirmation sent.');
|
||||
navigate('/vessel-transfers');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-transfers')}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Request Ownership Transfer</Title>
|
||||
<Text fz="sm" c="dimmed">Submit a request to transfer your vessel to a new owner.</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Step 0 — Select vessel */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Select Vessel</Text>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">Only approved vessels are available for transfer.</Text>
|
||||
</Alert>
|
||||
<Select
|
||||
label="Vessel"
|
||||
placeholder="Select a vessel…"
|
||||
data={MOCK_VESSELS.filter((v) => v.approved).map((v) => ({ value: v.id, label: `${v.name} — ${v.regNo}` }))}
|
||||
value={vesselId}
|
||||
onChange={setVesselId}
|
||||
required
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — New owner details */}
|
||||
{step === 1 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>New Owner Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Full Name / Company Name" value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} required />
|
||||
<TextInput label="National ID / TIN" value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} required />
|
||||
<TextInput label="Phone Number" value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} required />
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
value={newOwnerEmail}
|
||||
onChange={(e) => setNewOwnerEmail(e.currentTarget.value)}
|
||||
error={newOwnerEmail && !EMAIL_RE.test(newOwnerEmail) ? 'Enter a valid email' : undefined}
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Textarea label="Address" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} required />
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Reason + document */}
|
||||
{step === 2 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="md">
|
||||
<Text fw={700}>Transfer Reason & Document</Text>
|
||||
<Select
|
||||
label="Transfer Reason"
|
||||
placeholder="Select a reason…"
|
||||
data={[...TRANSFER_REASONS]}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
required
|
||||
/>
|
||||
<FileInput
|
||||
label={<Group gap={4}><Text fz="sm" fw={500}>Bill of Sale / Transfer Document</Text><Badge size="xs" color="red" variant="light">Required</Badge></Group>}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={document}
|
||||
onChange={setDocument}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Review */}
|
||||
{step === 3 && vessel && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack gap="lg">
|
||||
<Text fw={700}>Review & Submit</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{[
|
||||
['Vessel', `${vessel.name} — ${vessel.regNo}`],
|
||||
['Current Owner', currentOwner],
|
||||
['New Owner', newOwnerName],
|
||||
['National ID / TIN', newOwnerIdOrTin],
|
||||
['Phone', newOwnerPhone],
|
||||
['Email', newOwnerEmail],
|
||||
['Address', newOwnerAddress],
|
||||
['Transfer Reason', reason ?? ''],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider />
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed">Document:</Text>
|
||||
<Text fz="sm" c="blue.7">{document?.name}</Text>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />}>
|
||||
<Text fz="xs">By submitting you confirm the new owner details are accurate and the uploaded document is genuine.</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep((s) => s - 1)} disabled={step === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok) || (step === 2 && !step2Ok)}
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCheck size={14} />} onClick={handleSubmit}>
|
||||
Submit Request
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconArrowRight, IconInfoCircle, IconShip } from '@tabler/icons-react';
|
||||
import { MOCK_REQUESTS, STATUS_COLOR } from '../mock';
|
||||
|
||||
export function VesselTransferPage() {
|
||||
const navigate = useNavigate();
|
||||
// MOCK_REQUESTS is mutated in place by addTransferRequest — copy so React notices the change on navigation back.
|
||||
const [requests] = useState(() => [...MOCK_REQUESTS]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Vessel Ownership Transfers</Title>
|
||||
<Text fz="sm" c="dimmed">Request and track the transfer of a vessel's registered ownership.</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconShip size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => navigate('/vessel-transfers/apply')}>
|
||||
Request Ownership Transfer
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Transfer Requests</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No transfer requests yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{requests.map((req) => (
|
||||
<Card key={req.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconAnchor size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{req.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{req.vesselRegNo}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[req.status]} variant="light">{req.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
||||
<div><Text fz="xs" c="dimmed">Current Owner</Text><Text fz="sm" fw={500}>{req.currentOwner}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">New Owner</Text><Text fz="sm" fw={500}>{req.newOwnerName}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Reason</Text><Text fz="sm" fw={500}>{req.reason}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Submitted</Text><Text fz="sm" fw={500}>{req.submitted}</Text></div>
|
||||
</SimpleGrid>
|
||||
{req.status === 'Approved' && (
|
||||
<Alert variant="light" color="teal" icon={<IconInfoCircle size={13} />} p="xs">
|
||||
<Text fz="xs">Transfer approved on {req.approvedOn}. New certificates issued to {req.newOwnerName}.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{req.status === 'Rejected' && (
|
||||
<Alert variant="light" color="red" icon={<IconInfoCircle size={13} />} p="xs">
|
||||
<Text fz="xs">{req.rejectionReason}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const am: Translations = {
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
documents: 'ሰነዶቼ',
|
||||
notifications: 'ማሳወቂያዎች',
|
||||
profile: 'መገለጫ',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const en = {
|
||||
myApplication: 'My Application',
|
||||
certificates: 'Certificates',
|
||||
endorsements: 'Endorsements',
|
||||
vesselTransfers: 'Vessel Transfers',
|
||||
documents: 'My Documents',
|
||||
notifications: 'Notifications',
|
||||
profile: 'Profile',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconRubberStamp,
|
||||
IconSend,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -26,6 +27,7 @@ const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
||||
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
||||
{ to: '/vessel-transfers', label: 'Vessel Transfers', i18nKey: 'nav.vesselTransfers', icon: IconShip },
|
||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||
{ to: '/notifications',label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
|
||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
|
||||
@@ -38,6 +40,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||
'/certificates': { i18nKey: 'nav.certificates' },
|
||||
'/endorsements': { i18nKey: 'nav.endorsements' },
|
||||
'/vessel-transfers': { i18nKey: 'nav.vesselTransfers' },
|
||||
'/documents': { i18nKey: 'nav.documents' },
|
||||
'/notifications':{ i18nKey: 'nav.notifications' },
|
||||
'/profile': { i18nKey: 'nav.profile' },
|
||||
|
||||
@@ -32,6 +32,10 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
|
||||
// Vessel Ownership Transfer
|
||||
import { VesselTransferPage } from './features/vessel-transfer/pages/VesselTransferPage';
|
||||
import { VesselTransferApplicationPage } from './features/vessel-transfer/pages/VesselTransferApplicationPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public auth pages
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
@@ -84,6 +88,10 @@ export const router = createBrowserRouter([
|
||||
// Phase 3 — Endorsement
|
||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
||||
|
||||
// Vessel Ownership Transfer
|
||||
{ path: '/vessel-transfers', element: <VesselTransferPage /> },
|
||||
{ path: '/vessel-transfers/apply', element: <VesselTransferApplicationPage /> },
|
||||
|
||||
// General
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
|
||||
Reference in New Issue
Block a user