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