feat(vessel-registration): implement vessel registration feature with multi-step application process

- Added VesselRegistrationApplicationPage for submitting new vessel registrations.
- Created VesselRegistrationPage to list user's registrations and navigate to details.
- Implemented VesselRegistrationStatusPage to display registration status and download certificates.
- Integrated mock data for registrations and certificates.
- Updated navigation and routing to include vessel registration paths.
- Added translations for vessel registration in English and Amharic.
- Documented the vessel registration workflow and API integration notes.
This commit is contained in:
estifanos
2026-07-24 07:44:45 +00:00
parent 60ef3fc8b8
commit e482a4aca6
17 changed files with 2120 additions and 0 deletions

View File

@@ -0,0 +1,433 @@
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
export type RegistrationStatus =
| 'Pending'
| 'Under Review'
| 'Correction Required'
| 'Resubmitted'
| 'Approved'
| 'Rejected';
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
Pending: 'gray',
'Under Review': 'blue',
'Correction Required': 'orange',
Resubmitted: 'cyan',
Approved: 'teal',
Rejected: 'red',
};
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
export const RENEWAL_COLOR: Record<RenewalState, string> = {
OK: 'gray',
'Due Soon': 'orange',
Overdue: 'red',
};
export interface RegistrationOwner {
name: string;
idOrTin: string;
phone: string;
email: string;
address: string;
}
export interface TimelineStep {
date: string | null;
event: string;
done: boolean;
}
export interface RegistrationCertificate {
name: string;
number: string;
issueDate: string;
}
export interface RegistrationDocument {
key: string;
label: string;
fileName: string;
fileType: 'pdf' | 'image';
}
export interface VesselRegistration {
id: string;
category: VesselCategory;
status: RegistrationStatus;
submitted: string;
remarks?: string;
correctionFields?: string[];
expiryDate?: string;
renewal?: RenewalState;
timeline: TimelineStep[];
documents: RegistrationDocument[];
certificates?: RegistrationCertificate[];
// Vessel details
vesselName: string;
vesselType: string;
registrationArea: string;
flagState: string;
passengerCapacity?: string;
grossTonnage?: string;
length: string;
breadth: string;
depth: string;
// Technical
imoNumber?: string;
hullNumber?: string;
shipyard: string;
yearBuilt: string;
engineType: string;
engineNumber: string;
enginePower: string;
hullMaterial: string;
// Ownership
owner: RegistrationOwner;
}
export const CERTIFICATES: Record<VesselCategory, string[]> = {
'Inland Waterway': ['Inland Vessel Registration Certificate'],
'Sea-going': [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
],
};
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
{
id: 'VR-2025-0001',
category: 'Sea-going',
status: 'Under Review',
submitted: '2025-06-20',
timeline: [
SUBMITTED_STEP('2025-06-20'),
{ date: '2025-06-22', event: 'Document Verification', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'nile_star_photos.pdf', fileType: 'pdf' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'nile_star_bill_of_sale.pdf', fileType: 'pdf' },
{ key: 'particulars', label: 'Ship Particulars', fileName: 'nile_star_particulars.pdf', fileType: 'pdf' },
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'nile_star_insurance.pdf', fileType: 'pdf' },
],
vesselName: 'MV Nile Star',
vesselType: 'Bulk Carrier',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '18500',
length: '190',
breadth: '28',
depth: '15',
imoNumber: 'IMO9876543',
shipyard: 'Hyundai Heavy Industries',
yearBuilt: '2016',
engineType: 'Diesel',
engineNumber: 'ENG-44210',
enginePower: '12000 kW',
hullMaterial: 'Steel',
owner: {
name: 'Nile Shipping PLC',
idOrTin: 'TIN-0012345678',
phone: '+251911223344',
email: 'ops@nileshipping.et',
address: 'Bole Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0002',
category: 'Inland Waterway',
status: 'Correction Required',
submitted: '2025-06-10',
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
correctionFields: ['Vessel Photos'],
timeline: [
SUBMITTED_STEP('2025-06-10'),
{ date: '2025-06-12', event: 'Document Verification', done: true },
{ date: '2025-06-14', event: 'Correction Requested', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'tana_ferry3_photos.jpg', fileType: 'image' },
],
vesselName: 'Tana Ferry 3',
vesselType: 'Ferry',
registrationArea: 'Lake Tana',
flagState: 'Ethiopia',
passengerCapacity: '40',
length: '18',
breadth: '5',
depth: '2',
hullNumber: 'HN-2211',
shipyard: 'Bahir Dar Boat Works',
yearBuilt: '2020',
engineType: 'Outboard',
engineNumber: 'ENG-9931',
enginePower: '150 hp',
hullMaterial: 'Fiberglass',
owner: {
name: 'Getachew Alemu',
idOrTin: 'ID-4455667788',
phone: '+251922334455',
email: 'getachew.alemu@example.com',
address: 'Bahir Dar, Amhara',
},
},
{
id: 'VR-2025-0003',
category: 'Sea-going',
status: 'Approved',
submitted: '2025-04-05',
expiryDate: '2026-08-15',
renewal: 'Due Soon',
timeline: [
SUBMITTED_STEP('2025-04-05'),
{ date: '2025-04-08', event: 'Document Verification', done: true },
{ date: '2025-04-20', event: 'Inspection', done: true },
{ date: '2025-04-28', event: 'Approval', done: true },
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'abay_voyager_photos.pdf', fileType: 'pdf' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'abay_voyager_bill_of_sale.pdf', fileType: 'pdf' },
{ key: 'particulars', label: 'Ship Particulars', fileName: 'abay_voyager_particulars.pdf', fileType: 'pdf' },
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'abay_voyager_insurance.pdf', fileType: 'pdf' },
],
certificates: [
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28' },
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28' },
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28' },
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28' },
],
vesselName: 'MV Abay Voyager',
vesselType: 'General Cargo',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '9600',
length: '140',
breadth: '21',
depth: '11',
imoNumber: 'IMO9123456',
shipyard: 'Damen Shipyards',
yearBuilt: '2012',
engineType: 'Diesel',
engineNumber: 'ENG-33012',
enginePower: '7200 kW',
hullMaterial: 'Steel',
owner: {
name: 'Abay Maritime PLC',
idOrTin: 'TIN-0098765432',
phone: '+251933445566',
email: 'contact@abaymaritime.et',
address: 'Kirkos Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0004',
category: 'Inland Waterway',
status: 'Rejected',
submitted: '2025-03-02',
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
timeline: [
SUBMITTED_STEP('2025-03-02'),
{ date: '2025-03-05', event: 'Document Verification', done: true },
{ date: '2025-03-14', event: 'Inspection', done: true },
{ date: '2025-03-18', event: 'Rejected', done: true },
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'awash_cargo1_photos.jpg', fileType: 'image' },
],
vesselName: 'Awash Cargo 1',
vesselType: 'Cargo Barge',
registrationArea: 'Awash River Basin',
flagState: 'Ethiopia',
passengerCapacity: '0',
length: '22',
breadth: '6',
depth: '3',
hullNumber: 'HN-1187',
shipyard: 'Awash River Works',
yearBuilt: '2018',
engineType: 'Inboard',
engineNumber: 'ENG-5567',
enginePower: '210 hp',
hullMaterial: 'Steel',
owner: {
name: 'Selam Tesfaye',
idOrTin: 'ID-2233445566',
phone: '+251944556677',
email: 'selam.tesfaye@example.com',
address: 'Adama, Oromia',
},
},
{
id: 'VR-2025-0005',
category: 'Sea-going',
status: 'Pending',
submitted: '2025-07-10',
timeline: [
SUBMITTED_STEP('2025-07-10'),
PENDING_STEP('Document Verification'),
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'genale_pearl_photos.pdf', fileType: 'pdf' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'genale_pearl_bill_of_sale.pdf', fileType: 'pdf' },
{ key: 'particulars', label: 'Ship Particulars', fileName: 'genale_pearl_particulars.pdf', fileType: 'pdf' },
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'genale_pearl_insurance.pdf', fileType: 'pdf' },
],
vesselName: 'MV Genale Pearl',
vesselType: 'Container Ship',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '24300',
length: '210',
breadth: '30',
depth: '17',
imoNumber: 'IMO9345678',
shipyard: 'Samsung Heavy Industries',
yearBuilt: '2019',
engineType: 'Diesel',
engineNumber: 'ENG-51290',
enginePower: '15400 kW',
hullMaterial: 'Steel',
owner: {
name: 'Genale Maritime PLC',
idOrTin: 'TIN-0011223344',
phone: '+251911998877',
email: 'ops@genalemaritime.et',
address: 'Kirkos Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0006',
category: 'Inland Waterway',
status: 'Resubmitted',
submitted: '2025-06-01',
remarks: 'Re-uploaded clearer hull and deck photos as requested.',
timeline: [
SUBMITTED_STEP('2025-06-01'),
{ date: '2025-06-03', event: 'Document Verification', done: true },
{ date: '2025-06-05', event: 'Correction Requested', done: true },
{ date: '2025-06-18', event: 'Resubmitted', done: true },
PENDING_STEP('Approval'),
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'zeway_runner_photos_v2.jpg', fileType: 'image' },
],
vesselName: 'Zeway Runner',
vesselType: 'Passenger Boat',
registrationArea: 'Lake Ziway',
flagState: 'Ethiopia',
passengerCapacity: '25',
length: '14',
breadth: '4',
depth: '1.6',
hullNumber: 'HN-3092',
shipyard: 'Ziway Boat Works',
yearBuilt: '2021',
engineType: 'Outboard',
engineNumber: 'ENG-7712',
enginePower: '90 hp',
hullMaterial: 'Fiberglass',
owner: {
name: 'Mekdes Yohannes',
idOrTin: 'ID-5566778899',
phone: '+251955667788',
email: 'mekdes.y@example.com',
address: 'Ziway, Oromia',
},
},
{
id: 'VR-2025-0007',
category: 'Sea-going',
status: 'Approved',
submitted: '2024-08-01',
expiryDate: '2025-08-01',
renewal: 'Overdue',
timeline: [
SUBMITTED_STEP('2024-08-01'),
{ date: '2024-08-05', event: 'Document Verification', done: true },
{ date: '2024-08-18', event: 'Inspection', done: true },
{ date: '2024-08-25', event: 'Approval', done: true },
],
documents: [
{ key: 'photos', label: 'Vessel Photos', fileName: 'red_sea_trader_photos.pdf', fileType: 'pdf' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'red_sea_trader_bill_of_sale.pdf', fileType: 'pdf' },
{ key: 'particulars', label: 'Ship Particulars', fileName: 'red_sea_trader_particulars.pdf', fileType: 'pdf' },
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'red_sea_trader_insurance.pdf', fileType: 'pdf' },
],
certificates: [
{ name: 'Certificate of Nationality', number: 'CN-2024-0058', issueDate: '2024-08-25' },
{ name: 'Certificate of Ownership', number: 'CO-2024-0058', issueDate: '2024-08-25' },
{ name: 'Certificate of Registration', number: 'CR-2024-0058', issueDate: '2024-08-25' },
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2024-0058', issueDate: '2024-08-25' },
],
vesselName: 'MV Red Sea Trader',
vesselType: 'Tanker',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '31200',
length: '228',
breadth: '32',
depth: '19',
imoNumber: 'IMO9456789',
shipyard: 'Mitsubishi Heavy Industries',
yearBuilt: '2009',
engineType: 'Diesel',
engineNumber: 'ENG-28871',
enginePower: '18900 kW',
hullMaterial: 'Steel',
owner: {
name: 'Red Sea Tankers PLC',
idOrTin: 'TIN-0055443322',
phone: '+251911332211',
email: 'fleet@redseatankers.et',
address: 'Yeka Sub-city, Addis Ababa',
},
},
];
// ponytail: mutate mock, no live clock — swap for real API mutation when backend lands.
export function applyDecision(
reg: VesselRegistration,
status: RegistrationStatus,
remarks?: string,
correctionFields?: string[]
): void {
reg.status = status;
if (remarks) reg.remarks = remarks;
reg.correctionFields = status === 'Correction Required' ? correctionFields : undefined;
const eventLabel: Record<RegistrationStatus, string> = {
Pending: 'Pending',
'Under Review': 'Marked Under Review',
'Correction Required': 'Correction Requested',
Resubmitted: 'Resubmitted',
Approved: 'Approval',
Rejected: 'Rejected',
};
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
reg.timeline.push({ date: today, event: eventLabel[status], done: true });
}
export function generateCertificates(reg: VesselRegistration): void {
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
reg.certificates = CERTIFICATES[reg.category].map((name, i) => ({
name,
number: `${name.split(' ').map((w) => w[0]).join('').toUpperCase()}-2025-${String(1000 + i)}`,
issueDate: today ?? reg.submitted,
}));
}

View File

@@ -0,0 +1,169 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
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';
import { MOCK_REGISTRATIONS, RENEWAL_COLOR, STATUS_COLOR } from '../mock';
export function VesselRegistrationQueuePage() {
const navigate = useNavigate();
const [registrations] = useState(MOCK_REGISTRATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [renewalFilter, setRenewalFilter] = useState<string | null>(null);
const stats = {
total: registrations.length,
pending: registrations.filter((r) => r.status === 'Pending').length,
underReview: registrations.filter((r) => r.status === 'Under Review').length,
approved: registrations.filter((r) => r.status === 'Approved').length,
};
const filtered = registrations.filter((r) => {
const q = search.toLowerCase();
const matchSearch = !q
|| r.vesselName.toLowerCase().includes(q)
|| r.id.toLowerCase().includes(q)
|| r.owner.name.toLowerCase().includes(q);
const matchStatus = !statusFilter || r.status === statusFilter;
const matchCategory = !categoryFilter || r.category === categoryFilter;
const matchRenewal = !renewalFilter || r.renewal === renewalFilter;
return matchSearch && matchStatus && matchCategory && matchRenewal;
});
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration</Title>
<Text fz="sm" c="dimmed">Review and process vessel registration applications</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}>Registration Queue</Text>
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search by vessel, registration ID or owner…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(260) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
placeholder="All Status"
data={['Pending', 'Under Review', 'Correction Required', 'Resubmitted', 'Approved', 'Rejected']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(170) }}
/>
<Select
placeholder="All Categories"
data={['Inland Waterway', 'Sea-going']}
value={categoryFilter}
onChange={setCategoryFilter}
clearable
size="sm"
style={{ width: rem(160) }}
/>
<Select
placeholder="All Renewal"
data={['OK', 'Due Soon', 'Overdue']}
value={renewalFilter}
onChange={setRenewalFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
</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 registrations found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Registration ID', 'Vessel Name', 'Vessel Type', 'Category', 'Owner', 'Submitted', 'Status', 'Renewal', ''].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((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{reg.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={500}>{reg.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.vesselType}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.owner.name}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.submitted}</Text></Table.Td>
<Table.Td><Badge color={STATUS_COLOR[reg.status]} variant="light" size="xs">{reg.status}</Badge></Table.Td>
<Table.Td>{reg.renewal ? <Badge color={RENEWAL_COLOR[reg.renewal]} variant="light" size="xs">{reg.renewal}</Badge> : <Text fz="xs" c="dimmed"></Text>}</Table.Td>
<Table.Td>
<Button size="xs" variant="light" leftSection={<IconEye size={13} />} onClick={() => navigate(`/vessel-registrations/${reg.id}`)}>Review</Button>
</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 {registrations.length} registrations</Text>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,176 @@
import {
Badge,
Card,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconCircleCheck,
IconShip,
} from '@tabler/icons-react';
import { MOCK_REGISTRATIONS, RENEWAL_COLOR, STATUS_COLOR, type RegistrationStatus } from '../mock';
interface KpiSpec {
label: string;
value: number;
color: string;
icon: typeof IconShip;
}
function KpiCard({ label, value, color, icon: Icon }: KpiSpec) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb="xs">
<ThemeIcon variant="light" color={color} size={44} radius="md"><Icon size={22} stroke={1.6} /></ThemeIcon>
<Text fz="xl" fw={800}>{value.toLocaleString()}</Text>
</Group>
<Text fz="sm" fw={600}>{label}</Text>
</Card>
);
}
function DistributionBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
const pct = total ? Math.round((count / total) * 100) : 0;
return (
<div>
<Group justify="space-between" mb={4}>
<Badge color={color} variant="light" size="sm">{label}</Badge>
<Group gap="xs">
<Text fz="xs" fw={600}>{count}</Text>
<Text fz="xs" c="dimmed">{pct}%</Text>
</Group>
</Group>
<Progress value={pct} color={color} radius="xl" size="sm" />
</div>
);
}
const TYPE_COLORS = ['blue', 'teal', 'grape', 'orange', 'cyan', 'indigo', 'lime'];
export function VesselRegistrationReportPage() {
const registrations = MOCK_REGISTRATIONS;
const total = registrations.length;
const inland = registrations.filter((r) => r.category === 'Inland Waterway').length;
const seaGoing = registrations.filter((r) => r.category === 'Sea-going').length;
const approvedThisYear = registrations.filter((r) => r.status === 'Approved' && r.submitted.startsWith('2025')).length;
const statuses: RegistrationStatus[] = ['Pending', 'Under Review', 'Correction Required', 'Resubmitted', 'Approved', 'Rejected'];
const statusCounts = statuses
.map((s) => ({ status: s, count: registrations.filter((r) => r.status === s).length }))
.filter((s) => s.count > 0);
const typeCounts = Object.entries(
registrations.reduce<Record<string, number>>((acc, r) => {
acc[r.vesselType] = (acc[r.vesselType] ?? 0) + 1;
return acc;
}, {})
).sort((a, b) => b[1] - a[1]);
const recent = [...registrations]
.sort((a, b) => (a.submitted < b.submitted ? 1 : -1))
.slice(0, 10);
const renewals = registrations.filter((r) => r.renewal === 'Due Soon' || r.renewal === 'Overdue');
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration Report</Title>
<Text fz="sm" c="dimmed">Monitor registration activity and renewal status</Text>
</div>
{/* KPI cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<KpiCard label="Total Registered" value={total} color="blue" icon={IconShip} />
<KpiCard label="Inland Vessels" value={inland} color="cyan" icon={IconAnchor} />
<KpiCard label="Sea-going Vessels" value={seaGoing} color="indigo" icon={IconShip} />
<KpiCard label="Approved This Year" value={approvedThisYear} color="teal" icon={IconCircleCheck} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Status Distribution</Text>
<Stack gap="sm">
{statusCounts.map(({ status, count }) => (
<DistributionBar key={status} label={status} count={count} total={total} color={STATUS_COLOR[status]} />
))}
</Stack>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Vessel Type Breakdown</Text>
<Stack gap="sm">
{typeCounts.map(([type, count], i) => (
<DistributionBar key={type} label={type} count={count} total={total} color={TYPE_COLORS[i % TYPE_COLORS.length]} />
))}
</Stack>
</Paper>
</SimpleGrid>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Recent Registrations</Text>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Registration ID', 'Vessel', 'Category', 'Owner', 'Submitted', 'Status'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recent.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{r.id}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.owner.name}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.submitted}</Text></Table.Td>
<Table.Td><Badge color={STATUS_COLOR[r.status]} variant="light" size="xs">{r.status}</Badge></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Group gap="xs" mb="md">
<IconAlertTriangle size={16} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Renewal Tracking</Text>
</Group>
{renewals.length === 0 ? (
<Text fz="sm" c="dimmed">No vessels due for renewal.</Text>
) : (
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Vessel', 'Category', 'Expiry Date', 'Renewal Status'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renewals.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fz="xs" fw={500}>{r.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.expiryDate ?? '—'}</Text></Table.Td>
<Table.Td><Badge color={RENEWAL_COLOR[r.renewal!]} variant="light" size="xs">{r.renewal}</Badge></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,350 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Modal,
MultiSelect,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCircleCheck,
IconClock,
IconDownload,
IconExternalLink,
IconEye,
IconFileDescription,
IconInfoCircle,
IconUserQuestion,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
applyDecision,
generateCertificates,
MOCK_REGISTRATIONS,
STATUS_COLOR,
type RegistrationDocument,
type RegistrationStatus,
} from '../mock';
// ponytail: view/download hit this demo data-URI, wire to real file storage when backend lands.
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ label, fileName, fileType }: RegistrationDocument) {
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(180), 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>
);
}
export function VesselRegistrationReviewPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const record = MOCK_REGISTRATIONS.find((r) => r.id === id);
const [status, setStatus] = useState<RegistrationStatus | undefined>(record?.status);
const [remarks, setRemarks] = useState(record?.remarks ?? '');
const [correctionFields, setCorrectionFields] = useState<string[]>(record?.correctionFields ?? []);
const [, forceUpdate] = useState(0);
if (!record || !status) {
return (
<Stack gap="md">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-registrations')}><IconArrowLeft size={18} /></ActionIcon>
<Title order={3}>Vessel Registration</Title>
</Group>
<Alert color="red" icon={<IconInfoCircle size={16} />}>Registration not found.</Alert>
</Stack>
);
}
const isTerminal = status === 'Approved' || status === 'Rejected';
const decide = (newStatus: RegistrationStatus, label: string) => {
applyDecision(record, newStatus, remarks, newStatus === 'Correction Required' ? correctionFields : undefined);
if (newStatus === 'Approved') generateCertificates(record);
setStatus(newStatus);
forceUpdate((n) => n + 1);
notify.success(`Registration ${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-registrations')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>Registration 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>
{/* Decision 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 correction / rejection…"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<MultiSelect
label="Fields / Documents Requiring Correction (optional)"
placeholder="Select fields or documents…"
data={[...record.documents.map((d) => d.label), 'Vessel Details', 'Technical Specifications', 'Ownership Information']}
value={correctionFields}
onChange={setCorrectionFields}
clearable
/>
<Group gap="sm" justify="flex-end">
<Button
size="sm"
color="blue"
variant="light"
leftSection={<IconUserQuestion size={15} />}
onClick={() => decide('Under Review', 'marked under review')}
disabled={status === 'Under Review'}
>
Mark Under Review
</Button>
<Button
size="sm"
color="orange"
variant="light"
leftSection={<IconAlertTriangle size={15} />}
onClick={() => decide('Correction Required', 'sent back for correction')}
disabled={!remarks.trim()}
>
Request Correction
</Button>
<Button
size="sm"
color="red"
variant="light"
leftSection={<IconX size={15} />}
onClick={() => decide('Rejected', 'rejected')}
disabled={!remarks.trim()}
>
Reject
</Button>
<Button
size="sm"
color="teal"
leftSection={<IconCircleCheck size={15} />}
onClick={() => decide('Approved', 'approved')}
>
Approve
</Button>
</Group>
{!remarks.trim() && (
<Text fz="xs" c="dimmed" ta="right">Remarks are required to request correction or reject this application.</Text>
)}
</Stack>
</Paper>
) : (
<Alert
variant="light"
color={STATUS_COLOR[status]}
icon={status === 'Approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
{status === 'Approved'
? <>Registration approved. Certificates generated for {record.owner.name}.</>
: <>This registration was rejected. {record.remarks}</>}
</Alert>
)}
{/* Two-column body */}
<Grid gutter="md">
{/* Left column */}
<Grid.Col span={{ base: 12, lg: 7 }}>
<Stack gap="md">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Vessel Name" value={record.vesselName} />
<InfoRow label="Vessel Type" value={record.vesselType} />
<InfoRow label="Category" value={record.category} />
<InfoRow label="Registration Area" value={record.registrationArea} />
<InfoRow label="Flag State" value={record.flagState} />
<InfoRow
label={record.category === 'Sea-going' ? 'Gross Tonnage (GT)' : 'Passenger Capacity'}
value={(record.category === 'Sea-going' ? record.grossTonnage : record.passengerCapacity) ?? ''}
/>
<InfoRow label="Length / Breadth / Depth" value={`${record.length} / ${record.breadth} / ${record.depth}`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Technical Specifications</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow
label={record.category === 'Sea-going' ? 'IMO Number' : 'Hull Number'}
value={(record.category === 'Sea-going' ? record.imoNumber : record.hullNumber) ?? ''}
/>
<InfoRow label="Shipyard" value={record.shipyard} />
<InfoRow label="Year Built" value={record.yearBuilt} />
<InfoRow label="Engine Type" value={record.engineType} />
<InfoRow label="Engine Number" value={record.engineNumber} />
<InfoRow label="Engine Power" value={record.enginePower} />
<InfoRow label="Hull Material" value={record.hullMaterial} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Ownership Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Owner Name" value={record.owner.name} />
<InfoRow label="National ID / TIN" value={record.owner.idOrTin} />
<InfoRow label="Phone Number" value={record.owner.phone} />
<InfoRow label="Email Address" value={record.owner.email} />
<InfoRow label="Address" value={record.owner.address} />
</SimpleGrid>
</Paper>
</Stack>
</Grid.Col>
{/* Right column */}
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Documents</Text>
<Stack gap="sm">
{record.documents.map((doc) => <DocViewer key={doc.key} label={doc.label} fileName={doc.fileName} fileType={doc.fileType} />)}
</Stack>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Timeline</Text>
<Stepper active={record.timeline.filter((t) => t.done).length - 1} size="sm" color="teal" orientation="vertical">
{record.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
</Paper>
{(record.remarks || (record.correctionFields && record.correctionFields.length > 0)) && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="sm">Officer Remarks</Text>
{record.remarks && <Text fz="sm" mb="sm">{record.remarks}</Text>}
{record.correctionFields && record.correctionFields.length > 0 && (
<Group gap={6}>
{record.correctionFields.map((f) => <Badge key={f} size="sm" color="orange" variant="light">{f}</Badge>)}
</Group>
)}
</Paper>
)}
{status === 'Approved' && record.certificates && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Stack gap="sm">
{record.certificates.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="xs">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">No. {cert.number} Issued {cert.issueDate}</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={13} />}
component="a"
href={DEMO_PDF}
download={`${cert.name.replace(/\s+/g, '-')}-${cert.number}.pdf`}
>
Download
</Button>
</Group>
<Divider mt="sm" />
</div>
))}
</Stack>
</Paper>
)}
</Stack>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

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

View File

@@ -19,6 +19,8 @@ export const en = {
seamanBookQueue: 'Seaman Book Queue',
cocQueue: 'CoC / CoP Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselRegistrationReport: 'Registration Report',
vesselTransfers: 'Vessel Ownership Transfer',
seafarerRegistry: 'Seafarer Registry',
applications: 'Applications',

View File

@@ -7,6 +7,7 @@ import { logout } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import {
IconAnchor,
IconBook2,
IconChartBar,
IconCreditCard,
@@ -23,6 +24,7 @@ import {
IconQuestionMark,
IconClipboardList,
IconReport,
IconReportAnalytics,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -34,6 +36,8 @@ 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-registrations', label: 'nav.vesselRegistrations', icon: IconAnchor },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconReportAnalytics },
{ to: '/vessel-transfers', label: 'nav.vesselTransfers', icon: IconShip },
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },

View File

@@ -32,6 +32,9 @@ 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';
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
const router = createBrowserRouter([
{
@@ -66,6 +69,9 @@ const router = createBrowserRouter([
{ path: 'payment-config', element: <PaymentConfigPage /> },
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
{ path: 'vessel-registrations', element: <VesselRegistrationQueuePage /> },
{ path: 'vessel-registrations/:id', element: <VesselRegistrationReviewPage /> },
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-transfers', element: <VesselTransferQueuePage /> },
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
{ path: 'questions', element: <QuestionPage /> },

View File

@@ -0,0 +1,286 @@
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
export const VESSEL_TYPES: Record<VesselCategory, string[]> = {
'Inland Waterway': ['Passenger Boat', 'Cargo Barge', 'Ferry', 'Tugboat', 'Fishing Boat'],
'Sea-going': ['Bulk Carrier', 'Container Ship', 'Tanker', 'General Cargo', 'Passenger Ship'],
};
export const ENGINE_TYPES = ['Diesel', 'Inboard', 'Outboard', 'Electric', 'Steam'] as const;
export const HULL_MATERIALS = ['Steel', 'Aluminum', 'Fiberglass', 'Wood', 'Composite'] as const;
export interface RequiredDocSlot {
key: string;
label: string;
minCount?: number;
}
export const REQUIRED_DOCS: Record<VesselCategory, RequiredDocSlot[]> = {
'Inland Waterway': [{ key: 'photos', label: 'Vessel Photos', minCount: 2 }],
'Sea-going': [
{ key: 'photos', label: 'Vessel Photos' },
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale' },
{ key: 'particulars', label: 'Ship Particulars' },
{ key: 'insurance', label: 'Insurance Certificate' },
],
};
export const CERTIFICATES: Record<VesselCategory, string[]> = {
'Inland Waterway': ['Inland Vessel Registration Certificate'],
'Sea-going': [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
],
};
export type RegistrationStatus = 'Pending' | 'Under Review' | 'Correction Required' | 'Approved' | 'Rejected';
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
Pending: 'gray',
'Under Review': 'blue',
'Correction Required': 'orange',
Approved: 'teal',
Rejected: 'red',
};
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
export interface RegistrationOwner {
name: string;
idOrTin: string;
phone: string;
email: string;
address: string;
}
export interface RegistrationCertificate {
name: string;
number: string;
issueDate: string;
downloads: number;
}
export interface TimelineStep {
date: string | null;
event: string;
done: boolean;
}
export interface VesselRegistration {
id: string;
category: VesselCategory;
status: RegistrationStatus;
submitted: string;
remarks?: string;
expiryDate?: string;
renewal?: RenewalState;
timeline: TimelineStep[];
certificates?: RegistrationCertificate[];
// Vessel details
vesselName: string;
vesselType: string;
registrationArea: string;
flagState: string;
passengerCapacity?: string;
grossTonnage?: string;
length: string;
breadth: string;
depth: string;
// Technical
imoNumber?: string;
hullNumber?: string;
shipyard: string;
yearBuilt: string;
engineType: string;
engineNumber: string;
enginePower: string;
hullMaterial: string;
// Ownership
owner: RegistrationOwner;
}
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
{
id: 'VR-2025-0001',
category: 'Sea-going',
status: 'Under Review',
submitted: '2025-06-20',
timeline: [
SUBMITTED_STEP('2025-06-20'),
{ date: '2025-06-22', event: 'Document Verification', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
vesselName: 'MV Nile Star',
vesselType: 'Bulk Carrier',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '18500',
length: '190',
breadth: '28',
depth: '15',
imoNumber: 'IMO9876543',
shipyard: 'Hyundai Heavy Industries',
yearBuilt: '2016',
engineType: 'Diesel',
engineNumber: 'ENG-44210',
enginePower: '12000 kW',
hullMaterial: 'Steel',
owner: {
name: 'Nile Shipping PLC',
idOrTin: 'TIN-0012345678',
phone: '+251911223344',
email: 'ops@nileshipping.et',
address: 'Bole Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0002',
category: 'Inland Waterway',
status: 'Correction Required',
submitted: '2025-06-10',
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
timeline: [
SUBMITTED_STEP('2025-06-10'),
{ date: '2025-06-12', event: 'Document Verification', done: true },
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
vesselName: 'Tana Ferry 3',
vesselType: 'Ferry',
registrationArea: 'Lake Tana',
flagState: 'Ethiopia',
passengerCapacity: '40',
length: '18',
breadth: '5',
depth: '2',
hullNumber: 'HN-2211',
shipyard: 'Bahir Dar Boat Works',
yearBuilt: '2020',
engineType: 'Outboard',
engineNumber: 'ENG-9931',
enginePower: '150 hp',
hullMaterial: 'Fiberglass',
owner: {
name: 'Getachew Alemu',
idOrTin: 'ID-4455667788',
phone: '+251922334455',
email: 'getachew.alemu@example.com',
address: 'Bahir Dar, Amhara',
},
},
{
id: 'VR-2025-0003',
category: 'Sea-going',
status: 'Approved',
submitted: '2025-04-05',
expiryDate: '2026-08-15',
renewal: 'Due Soon',
timeline: [
SUBMITTED_STEP('2025-04-05'),
{ date: '2025-04-08', event: 'Document Verification', done: true },
{ date: '2025-04-20', event: 'Inspection', done: true },
{ date: '2025-04-28', event: 'Approval', done: true },
],
certificates: [
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28', downloads: 0 },
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28', downloads: 0 },
],
vesselName: 'MV Abay Voyager',
vesselType: 'General Cargo',
registrationArea: 'Djibouti Corridor',
flagState: 'Ethiopia',
grossTonnage: '9600',
length: '140',
breadth: '21',
depth: '11',
imoNumber: 'IMO9123456',
shipyard: 'Damen Shipyards',
yearBuilt: '2012',
engineType: 'Diesel',
engineNumber: 'ENG-33012',
enginePower: '7200 kW',
hullMaterial: 'Steel',
owner: {
name: 'Abay Maritime PLC',
idOrTin: 'TIN-0098765432',
phone: '+251933445566',
email: 'contact@abaymaritime.et',
address: 'Kirkos Sub-city, Addis Ababa',
},
},
{
id: 'VR-2025-0004',
category: 'Inland Waterway',
status: 'Rejected',
submitted: '2025-03-02',
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
timeline: [
SUBMITTED_STEP('2025-03-02'),
{ date: '2025-03-05', event: 'Document Verification', done: true },
{ date: '2025-03-14', event: 'Inspection', done: true },
{ date: '2025-03-18', event: 'Approval', done: false },
],
vesselName: 'Awash Cargo 1',
vesselType: 'Cargo Barge',
registrationArea: 'Awash River Basin',
flagState: 'Ethiopia',
passengerCapacity: '0',
length: '22',
breadth: '6',
depth: '3',
hullNumber: 'HN-1187',
shipyard: 'Awash River Works',
yearBuilt: '2018',
engineType: 'Inboard',
engineNumber: 'ENG-5567',
enginePower: '210 hp',
hullMaterial: 'Steel',
owner: {
name: 'Selam Tesfaye',
idOrTin: 'ID-2233445566',
phone: '+251944556677',
email: 'selam.tesfaye@example.com',
address: 'Adama, Oromia',
},
},
];
export function addRegistration(
reg: Omit<VesselRegistration, 'id' | 'status' | 'submitted' | 'timeline' | 'certificates'>
): VesselRegistration {
const submitted = new Date().toISOString().slice(0, 10);
const created: VesselRegistration = {
...reg,
id: `VR-2025-${String(MOCK_REGISTRATIONS.length + 1).padStart(4, '0')}`,
status: 'Pending',
submitted,
timeline: [
SUBMITTED_STEP(submitted),
PENDING_STEP('Document Verification'),
PENDING_STEP('Inspection'),
PENDING_STEP('Approval'),
],
};
MOCK_REGISTRATIONS.unshift(created);
return created;
}
// ponytail: in-memory counter, not persisted.
export function recordDownload(regId: string, certName: string): void {
const reg = MOCK_REGISTRATIONS.find((r) => r.id === regId);
const cert = reg?.certificates?.find((c) => c.name === certName);
if (cert) cert.downloads += 1;
}

View File

@@ -0,0 +1,367 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Stepper,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconArrowRight,
IconCheck,
IconInfoCircle,
IconSailboat,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
addRegistration,
ENGINE_TYPES,
HULL_MATERIALS,
REQUIRED_DOCS,
VESSEL_TYPES,
type VesselCategory,
} from '../mock';
import { useAppSelector } from '../../../store/hooks';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function VesselRegistrationApplicationPage() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.auth.user);
const [step, setStep] = useState(0);
// Step 0 — category
const [category, setCategory] = useState<VesselCategory | null>(null);
// Step 1 — vessel details
const [vesselName, setVesselName] = useState('');
const [vesselType, setVesselType] = useState<string | null>(null);
const [registrationArea, setRegistrationArea] = useState('');
const [flagState, setFlagState] = useState('Ethiopia');
const [passengerCapacity, setPassengerCapacity] = useState('');
const [grossTonnage, setGrossTonnage] = useState('');
const [length, setLength] = useState('');
const [breadth, setBreadth] = useState('');
const [depth, setDepth] = useState('');
// Step 2 — technical + ownership
const [imoNumber, setImoNumber] = useState('');
const [hullNumber, setHullNumber] = useState('');
const [shipyard, setShipyard] = useState('');
const [yearBuilt, setYearBuilt] = useState('');
const [engineType, setEngineType] = useState<string | null>(null);
const [engineNumber, setEngineNumber] = useState('');
const [enginePower, setEnginePower] = useState('');
const [hullMaterial, setHullMaterial] = useState<string | null>(null);
const [ownerName, setOwnerName] = useState(user?.name?.en || user?.username || '');
const [ownerIdOrTin, setOwnerIdOrTin] = useState('');
const [ownerPhone, setOwnerPhone] = useState('');
const [ownerEmail, setOwnerEmail] = useState('');
const [ownerAddress, setOwnerAddress] = useState('');
// Step 3 — documents
const [docs, setDocs] = useState<Record<string, File[] | null>>({});
const handleSelectCategory = (next: VesselCategory) => {
setCategory(next);
setVesselType(null);
setImoNumber('');
setHullNumber('');
setPassengerCapacity('');
setGrossTonnage('');
setDocs({});
setStep(1);
};
const requiredDocs = category ? REQUIRED_DOCS[category] : [];
const step1Ok = !!vesselName && !!vesselType && !!registrationArea && !!flagState
&& !!length && !!breadth && !!depth
&& (category === 'Sea-going' ? !!grossTonnage : !!passengerCapacity);
const step2Ok = !!shipyard && !!yearBuilt && !!engineType && !!engineNumber && !!enginePower && !!hullMaterial
&& (category === 'Sea-going' ? !!imoNumber : !!hullNumber)
&& !!ownerName && !!ownerIdOrTin && !!ownerPhone && EMAIL_RE.test(ownerEmail) && !!ownerAddress;
const step3Ok = requiredDocs.every((slot) => {
const files = docs[slot.key];
return !!files && files.length >= (slot.minCount ?? 1);
});
const docStatus = (slot: (typeof requiredDocs)[number]): { label: string; color: string } => {
const files = docs[slot.key];
if (!files || files.length === 0) return { label: 'Empty', color: 'gray' };
if (files.length < (slot.minCount ?? 1)) return { label: 'Missing Required Document', color: 'red' };
return { label: 'Uploaded', color: 'teal' };
};
const handleSubmit = () => {
if (!category || !vesselType || !engineType || !hullMaterial) return;
const created = addRegistration({
category,
vesselName,
vesselType,
registrationArea,
flagState,
passengerCapacity: category === 'Inland Waterway' ? passengerCapacity : undefined,
grossTonnage: category === 'Sea-going' ? grossTonnage : undefined,
length,
breadth,
depth,
imoNumber: category === 'Sea-going' ? imoNumber : undefined,
hullNumber: category === 'Inland Waterway' ? hullNumber : undefined,
shipyard,
yearBuilt,
engineType,
engineNumber,
enginePower,
hullMaterial,
owner: {
name: ownerName,
idOrTin: ownerIdOrTin,
phone: ownerPhone,
email: ownerEmail,
address: ownerAddress,
},
});
notify.success('Registration submitted. SMS and email confirmation sent.');
navigate(`/vessel-registrations/${created.id}`);
};
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<div>
<Title order={3}>Register a Vessel</Title>
<Text fz="sm" c="dimmed">Submit a new vessel registration application.</Text>
</div>
</Group>
<Stepper active={step} size="sm" color="blue" onStepClick={(s) => { if (category && s < step) setStep(s); }}>
<Stepper.Step label="Category" description="Vessel type" />
<Stepper.Step label="Vessel Details" description="Identification" />
<Stepper.Step label="Technical & Ownership" description="Specs & owner" />
<Stepper.Step label="Documents" description="Required uploads" />
<Stepper.Step label="Review & Submit" description="Final check" />
</Stepper>
{/* Step 0 — Category */}
{step === 0 && (
<Paper withBorder radius="lg" p="xl">
<Stack gap="md">
<Text fw={700}>Select Vessel Category</Text>
<Text fz="sm" c="dimmed">Choose the category that matches your vessel. This determines the fields, documents, and certificates for your registration.</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Card
withBorder
radius="md"
p="lg"
onClick={() => handleSelectCategory('Inland Waterway')}
style={{ cursor: 'pointer', borderColor: category === 'Inland Waterway' ? 'var(--mantine-color-blue-5)' : undefined }}
>
<ThemeIcon size={40} radius="md" color="blue" variant="light" mb="sm"><IconSailboat size={22} /></ThemeIcon>
<Text fw={700}>Inland Waterway Vessel</Text>
<Text fz="sm" c="dimmed">Boats, barges, ferries and other vessels operating on lakes and rivers.</Text>
</Card>
<Card
withBorder
radius="md"
p="lg"
onClick={() => handleSelectCategory('Sea-going')}
style={{ cursor: 'pointer', borderColor: category === 'Sea-going' ? 'var(--mantine-color-blue-5)' : undefined }}
>
<ThemeIcon size={40} radius="md" color="blue" variant="light" mb="sm"><IconAnchor size={22} /></ThemeIcon>
<Text fw={700}>Sea-going Vessel (International)</Text>
<Text fz="sm" c="dimmed">Vessels engaged in international sea voyages.</Text>
</Card>
</SimpleGrid>
</Stack>
</Paper>
)}
{/* Step 1 — Vessel details */}
{step === 1 && category && (
<Paper withBorder radius="lg" p="xl">
<Stack gap="md">
<Text fw={700}>Vessel Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Vessel Name" value={vesselName} onChange={(e) => setVesselName(e.currentTarget.value)} required />
<Select label="Vessel Type" placeholder="Select a type…" data={VESSEL_TYPES[category]} value={vesselType} onChange={setVesselType} required />
<TextInput label="Registration Area" value={registrationArea} onChange={(e) => setRegistrationArea(e.currentTarget.value)} required />
<TextInput label="Flag State" value={flagState} onChange={(e) => setFlagState(e.currentTarget.value)} required />
{category === 'Inland Waterway' ? (
<TextInput label="Passenger Capacity" value={passengerCapacity} onChange={(e) => setPassengerCapacity(e.currentTarget.value)} required />
) : (
<TextInput label="Gross Tonnage (GT)" value={grossTonnage} onChange={(e) => setGrossTonnage(e.currentTarget.value)} required />
)}
<TextInput label="Length (m)" value={length} onChange={(e) => setLength(e.currentTarget.value)} required />
<TextInput label="Breadth (m)" value={breadth} onChange={(e) => setBreadth(e.currentTarget.value)} required />
<TextInput label="Depth (m)" value={depth} onChange={(e) => setDepth(e.currentTarget.value)} required />
</SimpleGrid>
</Stack>
</Paper>
)}
{/* Step 2 — Technical + ownership */}
{step === 2 && category && (
<Paper withBorder radius="lg" p="xl">
<Stack gap="lg">
<Stack gap="md">
<Text fw={700}>Technical Specifications</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{category === 'Sea-going' ? (
<TextInput label="IMO Number" value={imoNumber} onChange={(e) => setImoNumber(e.currentTarget.value)} required />
) : (
<TextInput label="Hull Number" value={hullNumber} onChange={(e) => setHullNumber(e.currentTarget.value)} required />
)}
<TextInput label="Shipyard" value={shipyard} onChange={(e) => setShipyard(e.currentTarget.value)} required />
<TextInput label="Year Built" value={yearBuilt} onChange={(e) => setYearBuilt(e.currentTarget.value)} required />
<Select label="Engine Type" placeholder="Select…" data={[...ENGINE_TYPES]} value={engineType} onChange={setEngineType} required />
<TextInput label="Engine Number" value={engineNumber} onChange={(e) => setEngineNumber(e.currentTarget.value)} required />
<TextInput label="Engine Power" value={enginePower} onChange={(e) => setEnginePower(e.currentTarget.value)} required />
<Select label="Hull Material" placeholder="Select…" data={[...HULL_MATERIALS]} value={hullMaterial} onChange={setHullMaterial} required />
</SimpleGrid>
</Stack>
<Divider />
<Stack gap="md">
<Text fw={700}>Ownership Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Owner Name" value={ownerName} onChange={(e) => setOwnerName(e.currentTarget.value)} required />
<TextInput label="National ID / TIN" value={ownerIdOrTin} onChange={(e) => setOwnerIdOrTin(e.currentTarget.value)} required />
<TextInput label="Phone Number" value={ownerPhone} onChange={(e) => setOwnerPhone(e.currentTarget.value)} required />
<TextInput
label="Email Address"
value={ownerEmail}
onChange={(e) => setOwnerEmail(e.currentTarget.value)}
error={ownerEmail && !EMAIL_RE.test(ownerEmail) ? 'Enter a valid email' : undefined}
required
/>
</SimpleGrid>
<Textarea label="Address" value={ownerAddress} onChange={(e) => setOwnerAddress(e.currentTarget.value)} required />
</Stack>
</Stack>
</Paper>
)}
{/* Step 3 — Documents */}
{step === 3 && category && (
<Paper withBorder radius="lg" p="xl">
<Stack gap="md">
<Text fw={700}>Required Documents</Text>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
<Text fz="sm">Required documents depend on your vessel category. All mandatory documents must be uploaded before you can proceed.</Text>
</Alert>
{requiredDocs.map((slot) => {
const status = docStatus(slot);
return (
<FileInput
key={slot.key}
label={
<Group gap={4}>
<Text fz="sm" fw={500}>{slot.label}{slot.minCount ? ` (minimum ${slot.minCount})` : ''}</Text>
<Badge size="xs" color={status.color} variant="light">{status.label}</Badge>
</Group>
}
placeholder="Click to upload"
leftSection={<IconUpload size={14} />}
value={docs[slot.key] ?? []}
onChange={(files) => setDocs((prev) => ({ ...prev, [slot.key]: files }))}
accept=".pdf,.jpg,.jpeg,.png"
multiple
clearable
/>
);
})}
</Stack>
</Paper>
)}
{/* Step 4 — Review */}
{step === 4 && category && (
<Paper withBorder radius="lg" p="xl">
<Stack gap="lg">
<Text fw={700}>Review & Submit</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{[
['Category', category],
['Vessel Name', vesselName],
['Vessel Type', vesselType ?? ''],
['Registration Area', registrationArea],
['Flag State', flagState],
[category === 'Sea-going' ? 'Gross Tonnage (GT)' : 'Passenger Capacity', category === 'Sea-going' ? grossTonnage : passengerCapacity],
['Length / Breadth / Depth', `${length} / ${breadth} / ${depth}`],
[category === 'Sea-going' ? 'IMO Number' : 'Hull Number', category === 'Sea-going' ? imoNumber : hullNumber],
['Shipyard', shipyard],
['Year Built', yearBuilt],
['Engine Type', engineType ?? ''],
['Engine Number', engineNumber],
['Engine Power', enginePower],
['Hull Material', hullMaterial ?? ''],
['Owner Name', ownerName],
['National ID / TIN', ownerIdOrTin],
['Phone', ownerPhone],
['Email', ownerEmail],
['Address', ownerAddress],
].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 />
<Stack gap={4}>
<Text fz="xs" fw={700} tt="uppercase" c="dimmed">Documents</Text>
{requiredDocs.map((slot) => (
<Text key={slot.key} fz="sm" c="blue.7">
{slot.label}: {(docs[slot.key] ?? []).map((f) => f.name).join(', ') || '—'}
</Text>
))}
</Stack>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />}>
<Text fz="xs">By submitting you confirm the information above is accurate and the uploaded documents are genuine.</Text>
</Alert>
</Stack>
</Paper>
)}
{/* Navigation */}
{step > 0 && (
<Group justify="space-between" mt="md">
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
{step < 4 ? (
<Button
rightSection={<IconArrowRight size={14} />}
disabled={(step === 1 && !step1Ok) || (step === 2 && !step2Ok) || (step === 3 && !step3Ok)}
onClick={() => setStep((s) => s + 1)}
>
Next
</Button>
) : (
<Button color="teal" leftSection={<IconCheck size={14} />} onClick={handleSubmit}>
Submit Registration
</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 { IconAlertTriangle, IconAnchor, IconArrowRight, IconInfoCircle, IconShip } from '@tabler/icons-react';
import { MOCK_REGISTRATIONS, STATUS_COLOR } from '../mock';
export function VesselRegistrationPage() {
const navigate = useNavigate();
// MOCK_REGISTRATIONS is mutated in place by addRegistration — copy so React notices the change on navigation back.
const [registrations] = useState(() => [...MOCK_REGISTRATIONS]);
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Vessel Registration</Title>
<Text fz="sm" c="dimmed">Register a new vessel and track your registration applications.</Text>
</div>
<Button leftSection={<IconAnchor size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => navigate('/vessel-registrations/apply')}>
Register a Vessel
</Button>
</Group>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Registrations</Text>
{registrations.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No vessel registrations yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{registrations.map((reg) => (
<Card
key={reg.id}
withBorder
radius="md"
p="md"
onClick={() => navigate(`/vessel-registrations/${reg.id}`)}
style={{ cursor: 'pointer' }}
>
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{reg.vesselName}</Text>
<Text fz="xs" c="dimmed">{reg.id} {reg.category}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[reg.status]} variant="light">{reg.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Vessel Type</Text><Text fz="sm" fw={500}>{reg.vesselType}</Text></div>
<div><Text fz="xs" c="dimmed">Submitted</Text><Text fz="sm" fw={500}>{reg.submitted}</Text></div>
</SimpleGrid>
{reg.renewal && reg.renewal !== 'OK' && (
<Alert variant="light" color={reg.renewal === 'Overdue' ? 'red' : 'orange'} icon={<IconAlertTriangle size={13} />} p="xs" mt="sm">
<Text fz="xs">Renewal {reg.renewal === 'Overdue' ? 'overdue' : 'due soon'}{reg.expiryDate ? ` — expires ${reg.expiryDate}` : ''}.</Text>
</Alert>
)}
</Card>
))}
</SimpleGrid>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,156 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCircleCheck,
IconClock,
IconDownload,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_REGISTRATIONS, recordDownload, STATUS_COLOR } from '../mock';
// ponytail: placeholder PDF blob; wire real cert endpoint when backend lands.
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
function downloadCertificate(filename: string) {
const a = document.createElement('a');
a.href = BLANK_PDF;
a.download = filename;
a.click();
}
export function VesselRegistrationStatusPage() {
const navigate = useNavigate();
const { id } = useParams();
const [reg] = useState(() => MOCK_REGISTRATIONS.find((r) => r.id === id) ?? null);
const [, forceUpdate] = useState(0);
if (!reg) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert>
</Stack>
);
}
const activeStep = reg.timeline.filter((t) => t.done).length - 1;
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
const handleDownload = (certName: string, certNumber: string) => {
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
recordDownload(reg.id, certName);
forceUpdate((n) => n + 1);
notify.success(`${certName} downloaded.`);
};
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
<div>
<Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} {reg.category}</Text>
</div>
</Group>
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Registration Status</Text>
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
</Group>
{reg.renewal && reg.renewal !== 'OK' && (
<Alert
variant="light"
color={reg.renewal === 'Overdue' ? 'red' : 'orange'}
icon={<IconAlertTriangle size={15} />}
mb="md"
p="sm"
>
<Text fz="sm">
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
{reg.expiryDate ? ` — expires ${reg.expiryDate}` : ''}.
</Text>
</Alert>
)}
{reg.remarks && (
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text>
<Text fz="sm">{reg.remarks}</Text>
</Alert>
)}
<Stepper active={activeStep} size="sm" color="teal">
{reg.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
{needsCorrection && (
<Group justify="flex-end" mt="md">
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button>
</Group>
)}
</Paper>
{reg.status === 'Approved' && reg.certificates && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Stack gap="sm">
{reg.certificates.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="sm">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {cert.issueDate}</Text>
{cert.downloads > 0 && (
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
)}
</div>
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => handleDownload(cert.name, cert.number)}
>
Download
</Button>
</Group>
<Divider mt="sm" />
</div>
))}
</Stack>
</Paper>
)}
</Stack>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconBell,
IconFolderOpen,
IconHeadset,
@@ -27,6 +28,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-registrations', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistrations', icon: IconAnchor },
{ 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 },
@@ -40,6 +42,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
'/seaman-book': { i18nKey: 'nav.myApplication' },
'/certificates': { i18nKey: 'nav.certificates' },
'/endorsements': { i18nKey: 'nav.endorsements' },
'/vessel-registrations': { i18nKey: 'nav.vesselRegistrations' },
'/vessel-transfers': { i18nKey: 'nav.vesselTransfers' },
'/documents': { i18nKey: 'nav.documents' },
'/notifications':{ i18nKey: 'nav.notifications' },

View File

@@ -36,6 +36,11 @@ import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
import { VesselTransferPage } from './features/vessel-transfer/pages/VesselTransferPage';
import { VesselTransferApplicationPage } from './features/vessel-transfer/pages/VesselTransferApplicationPage';
// Vessel Registration
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
import { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
import { VesselRegistrationStatusPage } from './features/vessel-registration/pages/VesselRegistrationStatusPage';
export const router = createBrowserRouter([
// Public auth pages
{ path: '/login', element: <LoginPage /> },
@@ -88,6 +93,11 @@ export const router = createBrowserRouter([
// Phase 3 — Endorsement
{ path: '/endorsements', element: <EndorsementPage /> },
// Vessel Registration
{ path: '/vessel-registrations', element: <VesselRegistrationPage /> },
{ path: '/vessel-registrations/apply', element: <VesselRegistrationApplicationPage /> },
{ path: '/vessel-registrations/:id', element: <VesselRegistrationStatusPage /> },
// Vessel Ownership Transfer
{ path: '/vessel-transfers', element: <VesselTransferPage /> },
{ path: '/vessel-transfers/apply', element: <VesselTransferApplicationPage /> },

View File

@@ -0,0 +1,73 @@
# Vessel Registration — mock implementation notes (for API integration)
Portal (owner-facing) + Backoffice (officer/manager-facing) built as **mock UI only**, matching each app's existing mock-driven review-workflow convention (same pattern as `docs/vessel-ownership-transfer.md`). No backend calls anywhere. This doc exists to make wiring the real API fast — it says exactly what to replace and where.
## Workflow
```mermaid
flowchart TD
A["1. Vessel owner opens Vessel Registration"] --> B{"2. Select vessel category"}
B -->|Inland Waterway Vessel| C["3. Enter vessel details"]
B -->|Sea-going Vessel International| C
C --> D["4. Enter technical & ownership details"]
D --> E["5. Upload required documents"]
E --> F["6. Review application"]
F --> G["7. Submit application"]
G --> H["8. System sends confirmation notification\n(SMS + email)"]
H --> I["9. Officer reviews application\nin registration queue"]
I --> J{"10. Officer takes action"}
J -->|Under Review| I
J -->|Correction Required| K["11. Owner updates & resubmits"]
K --> I
J -->|Rejected| Z1["Terminal: Rejected\n(remarks visible to owner)"]
J -->|Approved| L["12. System generates certificates\n(1 for Inland, 4 for Sea-going)"]
L --> M["13. Owner downloads certificates"]
M --> N["14. System tracks renewal status\n(OK / Due Soon / Overdue)"]
N --> O["15. Manager views registration reports"]
```
Status lifecycle: `Pending → Under Review → (Correction Required → Resubmitted →)* → Approved | Rejected`. `Approved`/`Rejected` are terminal — no further officer action possible once reached.
## What exists
### Portal (`apps/portal/src/app/features/vessel-registration/`)
- `mock.ts` — types (`VesselCategory`, `RegistrationStatus`, `VesselRegistration`, …), reference data (`VESSEL_TYPES`, `ENGINE_TYPES`, `HULL_MATERIALS`, `REQUIRED_DOCS`, `CERTIFICATES` per category), `MOCK_REGISTRATIONS`, `addRegistration()`, `recordDownload()`
- `pages/VesselRegistrationPage.tsx` — list of the owner's registrations (`/vessel-registrations`)
- `pages/VesselRegistrationApplicationPage.tsx` — 5-step submit wizard: Category → Vessel Details → Technical & Ownership → Documents → Review (`/vessel-registrations/apply`)
- `pages/VesselRegistrationStatusPage.tsx` — status detail: timeline, renewal alert, officer remarks + resubmit, certificate downloads (`/vessel-registrations/:id`)
### Backoffice (`apps/backoffice/src/app/features/vessel-registration/`)
- `mock.ts` — separate types + `MOCK_REGISTRATIONS` (officer-side shape, includes `documents[]` and `correctionFields?`), `applyDecision()`, `generateCertificates()`
- `pages/VesselRegistrationQueuePage.tsx` — queue table, stats, search + 3 filters (status/category/renewal) (`/vessel-registrations`)
- `pages/VesselRegistrationReviewPage.tsx` — two-column review: vessel/technical/ownership info + documents/timeline/remarks/certificates; decision bar (Mark Under Review / Request Correction / Reject / Approve) (`/vessel-registrations/:id`)
- `pages/VesselRegistrationReportPage.tsx` — manager report: KPI cards, status distribution, vessel-type breakdown, recent-10 table, renewal-tracking table (`/vessel-registration-report`)
Each app defines its **own separate mock array** (codebase convention: no shared domain types in `libs/`), and they don't sync — submitting in portal does not appear in the backoffice queue. That's the main thing a real API fixes. The two `VesselRegistration` shapes are already close (both modeled on the same field set) but not identical — see below.
## Data shape mismatch to resolve
- **Portal `VesselRegistration`** has no `documents[]` array — uploaded files live only as transient `File[]` in the wizard's component state (`docs: Record<string, File[] | null>` in `VesselRegistrationApplicationPage.tsx`) and are **never persisted** to the mock record; only file names are shown in the review step. **Known gap**: when wiring the real POST, the upload payload needs to be sent and stored — today it's dropped after submit.
- **Backoffice `VesselRegistration`** has `documents: RegistrationDocument[]` (`{key, label, fileName, fileType}`) so the officer review page has something to show — this is invented/seeded mock data, not real uploads. The real API should standardize on the **backoffice shape** (documents as first-class persisted records) since it's the superset the officer review page needs.
- **Certificates**: portal's `RegistrationCertificate` has a `downloads` counter (incremented client-side via `recordDownload()`); backoffice's does not track downloads. Real API should own download-count as a server-side audit log, not a client counter.
- **Correction targeting**: only the backoffice shape has `correctionFields?: string[]` (officer picks which fields/docs need fixing via a `MultiSelect`). Portal has no corresponding "these are the fields you need to fix" UI on the status page beyond the free-text `remarks` — worth adding when the real API returns `correctionFields`, so the owner can be pointed at the exact fields.
- **Status enum**: portal has 5 statuses (`Pending | Under Review | Correction Required | Approved | Rejected`); backoffice has 6 (adds `Resubmitted`, distinct from `Correction Required`, for after the owner has acted). The real API should use the backoffice's 6-value enum — portal's status page should render `Resubmitted` (currently unhandled — it'll fall through to no special UI).
## Suggested API surface
- `POST /vessel-registrations` — submit. Multipart body: all wizard fields (see `VesselRegistration` in either `mock.ts` for the full field list) + document files keyed by the `REQUIRED_DOCS[category]` slot key. Replaces `addRegistration()` in portal's `mock.ts`.
- `GET /vessel-registrations?ownerId=:id` — portal's "My Registrations" list (`VesselRegistrationPage.tsx`).
- `GET /vessel-registrations/:id` — used by portal's status page and backoffice's review page alike (both key off the same id).
- `GET /vessel-registrations` (officer, all + filters: `status`, `category`, `renewal`, `q`) — backoffice queue (`VesselRegistrationQueuePage.tsx`); filtering can move server-side or stay client-side over the fetched page as today.
- `PATCH /vessel-registrations/:id` — officer decision. Body: `{ status: 'Under Review' | 'Correction Required' | 'Rejected' | 'Approved', remarks?: string, correctionFields?: string[] }`. Replaces `applyDecision()` in backoffice's `mock.ts`. Backend should enforce remarks-required for `Correction Required`/`Rejected` (UI already gates this client-side, but don't trust it alone).
- `POST /vessel-registrations/:id/resubmit` — owner resubmit after correction (portal's "Resubmit Application" button on `VesselRegistrationStatusPage.tsx`, currently just routes back to the wizard with no state carried over — real flow should prefill the wizard from the existing record and only require the flagged fields/docs).
- On `PATCH .../:id` with `status: 'Approved'`: backend generates certificate records per `CERTIFICATES[category]` (1 for Inland, 4 for Sea-going) — replaces `generateCertificates()` in backoffice's `mock.ts`.
- `GET /vessel-registrations/:id/certificates/:certId/download` — real file download + audit log entry, replacing the shared `DEMO_PDF` base64 placeholder used by both apps' download buttons.
- `GET /vessel-registration-reports/summary` — KPIs + status distribution + type breakdown for `VesselRegistrationReportPage.tsx` (or compute client-side from a full `GET /vessel-registrations` if volume stays low — current mock computes everything client-side from the in-memory array).
- Document storage: real upload + signed URL for View, and a real download endpoint — today `getDocUrl()` in `VesselRegistrationReviewPage.tsx` (backoffice) fakes it with a hardcoded base64 PDF / placehold.co image, same pattern as the vessel-transfer feature.
- 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 in either app.
## Where to swap mock for real calls
- Portal: `apps/portal/src/app/features/vessel-registration/mock.ts` (whole file), plus the `useState`/local reads of `MOCK_REGISTRATIONS` in all three page files, and the transient `File[]` state in `VesselRegistrationApplicationPage.tsx` (needs to become a real multipart upload on submit).
- Backoffice: `apps/backoffice/src/app/features/vessel-registration/mock.ts` (whole file), plus the direct `applyDecision(record, ...)` / `generateCertificates(record)` mutations in `VesselRegistrationReviewPage.tsx` (swap for a mutation call + refetch/cache-invalidate), and the client-side aggregation in `VesselRegistrationReportPage.tsx` (swap for the summary endpoint above, or keep as a derived selector over cached query data).
- Both apps already have an RTK Query base (`@ema-platform/api``baseApi.injectEndpoints`, see `apps/portal/src/app/features/payment/api/payment-api.ts` or `apps/backoffice/src/app/features/certification/api/certification-api.ts`) — follow that pattern rather than the ad-hoc `useApiQuery`/`useApiMutation` escape hatch.