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:
estifanos
2026-07-23 12:56:16 +00:00
parent 875b56de8a
commit 60ef3fc8b8
14 changed files with 1034 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -21,6 +21,7 @@ export const am: Translations = {
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
cocQueue: 'የCoC/CoP ወረፋ',
endorsementQueue: 'የማረጋገጫ ወረፋ',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
seafarerRegistry: 'የመርከበኞች መዝገብ',
applications: 'ማመልከቻዎች',
paymentConfig: 'የክፍያ ውቅረት',

View File

@@ -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',

View File

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

View File

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

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -19,6 +19,7 @@ export const am: Translations = {
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
endorsements: 'ማረጋገጫዎች',
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
documents: 'ሰነዶቼ',
notifications: 'ማሳወቂያዎች',
profile: 'መገለጫ',

View File

@@ -17,6 +17,7 @@ export const en = {
myApplication: 'My Application',
certificates: 'Certificates',
endorsements: 'Endorsements',
vesselTransfers: 'Vessel Transfers',
documents: 'My Documents',
notifications: 'Notifications',
profile: 'Profile',

View File

@@ -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' },

View File

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

View File

@@ -0,0 +1,39 @@
# Vessel Ownership Transfer — mock implementation notes (for API integration)
Portal (owner-facing) + Backoffice (officer-facing) built as **mock UI only**, matching each app's existing mock-driven review-workflow convention. No backend calls anywhere. This doc exists to make wiring the real API fast — it says exactly what to replace and where.
## What exists
### Portal (`apps/portal/src/app/features/vessel-transfer/`)
- `mock.ts` — types + `MOCK_VESSELS`, `MOCK_REQUESTS`, `addTransferRequest()`
- `pages/VesselTransferPage.tsx` — list/status view (`/vessel-transfers`)
- `pages/VesselTransferApplicationPage.tsx` — 4-step submit wizard (`/vessel-transfers/apply`)
### Backoffice (`apps/backoffice/src/app/features/vessel-transfer/`)
- `pages/VesselTransferQueuePage.tsx` — types + `MOCK_REQUESTS`, queue table, stats, quick-view drawer (`/vessel-transfers`)
- `pages/VesselTransferReviewPage.tsx` — full review, approve/reject, doc view/download (`/vessel-transfers/:id`)
Each app defines its **own separate mock array** (codebase convention: no shared domain types in `libs/`). They are not the same objects and don't sync — submitting in portal does not appear in the backoffice queue, and vice versa. That's the main thing a real API fixes.
## Data shape mismatch to resolve
Portal's `TransferRequest.currentOwner` / `newOwnerName` are plain strings. Backoffice's `TransferRequest.currentOwner` / `newOwner` are full objects (`{ name, idOrTin, phone, email, address }`). The real API should standardize on the **backoffice shape** — it's the superset the officer review page needs.
**Known gap:** `VesselTransferApplicationPage.tsx`'s wizard collects `newOwnerIdOrTin`, `newOwnerPhone`, `newOwnerEmail`, `newOwnerAddress` and shows them in the review step, but `addTransferRequest()` only persists `newOwnerName` — the rest are captured in UI state and silently dropped on submit. When wiring the real POST, send all of them (the fields already exist in component state, just not in the payload today).
## Suggested API surface
- `GET /vessels?ownerId=:id&status=approved` — portal wizard's vessel `<Select>` (replaces `MOCK_VESSELS.filter(v => v.approved)` in `VesselTransferApplicationPage.tsx`).
- `POST /vessel-transfers` — submit. Body: `vesselId, newOwner: {name, idOrTin, phone, email, address}, reason, document (file upload)`. Replaces `addTransferRequest()` in portal's `mock.ts`.
- `GET /vessel-transfers?ownerId=:id` — portal's "My Transfer Requests" list (`VesselTransferPage.tsx`).
- `GET /vessel-transfers` (officer, all + filters) — backoffice queue (`VesselTransferQueuePage.tsx`); search/status-filter can move server-side or stay client-side over the fetched page.
- `GET /vessel-transfers/:id` — backoffice review page and portal status detail both key off this.
- `PATCH /vessel-transfers/:id` — officer action. Body: `{ status: 'Under Review' | 'Approved' | 'Rejected', remarks? }`. Replaces the in-place `record.status = ...` mutation in `VesselTransferReviewPage.tsx`. Backend should enforce remarks-required-on-Rejected (UI already gates this, but don't trust client-only validation).
- Document storage: real upload + signed URL for View, and a real download endpoint — today `getDocUrl()` in both `CoCReviewPage`-style `DocViewer`s and this feature's `VesselTransferReviewPage.tsx` fake it with a hardcoded base64 PDF / placehold.co image.
- SMS/email: triggered server-side on submit and on every status change — UI currently just shows a toast claiming this happened (`notify.success('... SMS and email ...')`); no actual send anywhere.
## Where to swap mock for real calls
- Portal: `apps/portal/src/app/features/vessel-transfer/mock.ts` (whole file), plus the `useState`/local reads of `MOCK_VESSELS`/`MOCK_REQUESTS` in both page files.
- Backoffice: the exported `MOCK_REQUESTS`/types block at the top of `VesselTransferQueuePage.tsx`, plus the direct `record.status = ...` mutation in `VesselTransferReviewPage.tsx` (swap for a mutation call + refetch/cache-invalidate).
- Both apps already have an RTK Query base (`@ema-platform/api``baseApi.injectEndpoints`, see `apps/portal/.../payment/api/payment-api.ts` or `apps/backoffice/.../certification/api/certification-api.ts`) — follow that pattern rather than the ad-hoc `useApiQuery`/`useApiMutation` escape hatch.