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