mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal file
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export const am: Translations = {
|
|||||||
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
|
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
|
||||||
cocQueue: 'የCoC/CoP ወረፋ',
|
cocQueue: 'የCoC/CoP ወረፋ',
|
||||||
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
||||||
|
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||||
|
vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||||
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
||||||
applications: 'ማመልከቻዎች',
|
applications: 'ማመልከቻዎች',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const en = {
|
|||||||
seamanBookQueue: 'Seaman Book Queue',
|
seamanBookQueue: 'Seaman Book Queue',
|
||||||
cocQueue: 'CoC / CoP Queue',
|
cocQueue: 'CoC / CoP Queue',
|
||||||
endorsementQueue: 'Endorsement Queue',
|
endorsementQueue: 'Endorsement Queue',
|
||||||
|
vesselRegistrations: 'Vessel Registration',
|
||||||
|
vesselRegistrationReport: 'Registration Report',
|
||||||
vesselTransfers: 'Vessel Ownership Transfer',
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { logout } from '@ema-platform/auth';
|
|||||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||||
import type { NavItem } from '@ema-platform/ui';
|
import type { NavItem } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
|
IconAnchor,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconReport,
|
IconReport,
|
||||||
|
IconReportAnalytics,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
@@ -34,6 +36,8 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
{ 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: '/vessel-transfers', label: 'nav.vesselTransfers', icon: IconShip },
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||||
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
|||||||
import { ResultPage } from '../features/result/pages/ResultPage';
|
import { ResultPage } from '../features/result/pages/ResultPage';
|
||||||
import { VesselTransferQueuePage } from '../features/vessel-transfer/pages/VesselTransferQueuePage';
|
import { VesselTransferQueuePage } from '../features/vessel-transfer/pages/VesselTransferQueuePage';
|
||||||
import { VesselTransferReviewPage } from '../features/vessel-transfer/pages/VesselTransferReviewPage';
|
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([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -66,6 +69,9 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
{ 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', element: <VesselTransferQueuePage /> },
|
||||||
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
|
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
|
||||||
{ path: 'questions', element: <QuestionPage /> },
|
{ path: 'questions', element: <QuestionPage /> },
|
||||||
|
|||||||
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal file
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export const am: Translations = {
|
|||||||
myApplication: 'ማመልከቻዬ',
|
myApplication: 'ማመልከቻዬ',
|
||||||
certificates: 'የምስክር ወረቀቶች',
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
endorsements: 'ማረጋገጫዎች',
|
endorsements: 'ማረጋገጫዎች',
|
||||||
|
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||||
documents: 'ሰነዶቼ',
|
documents: 'ሰነዶቼ',
|
||||||
notifications: 'ማሳወቂያዎች',
|
notifications: 'ማሳወቂያዎች',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const en = {
|
|||||||
myApplication: 'My Application',
|
myApplication: 'My Application',
|
||||||
certificates: 'Certificates',
|
certificates: 'Certificates',
|
||||||
endorsements: 'Endorsements',
|
endorsements: 'Endorsements',
|
||||||
|
vesselRegistrations: 'Vessel Registration',
|
||||||
vesselTransfers: 'Vessel Transfers',
|
vesselTransfers: 'Vessel Transfers',
|
||||||
documents: 'My Documents',
|
documents: 'My Documents',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppShell } from '@mantine/core';
|
import { AppShell } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
|
IconAnchor,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconFolderOpen,
|
IconFolderOpen,
|
||||||
IconHeadset,
|
IconHeadset,
|
||||||
@@ -27,6 +28,7 @@ const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
|||||||
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
||||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
{ 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: '/vessel-transfers', label: 'Vessel Transfers', i18nKey: 'nav.vesselTransfers', icon: IconShip },
|
||||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||||
{ to: '/notifications',label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
|
{ 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' },
|
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||||
'/certificates': { i18nKey: 'nav.certificates' },
|
'/certificates': { i18nKey: 'nav.certificates' },
|
||||||
'/endorsements': { i18nKey: 'nav.endorsements' },
|
'/endorsements': { i18nKey: 'nav.endorsements' },
|
||||||
|
'/vessel-registrations': { i18nKey: 'nav.vesselRegistrations' },
|
||||||
'/vessel-transfers': { i18nKey: 'nav.vesselTransfers' },
|
'/vessel-transfers': { i18nKey: 'nav.vesselTransfers' },
|
||||||
'/documents': { i18nKey: 'nav.documents' },
|
'/documents': { i18nKey: 'nav.documents' },
|
||||||
'/notifications':{ i18nKey: 'nav.notifications' },
|
'/notifications':{ i18nKey: 'nav.notifications' },
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
|||||||
import { VesselTransferPage } from './features/vessel-transfer/pages/VesselTransferPage';
|
import { VesselTransferPage } from './features/vessel-transfer/pages/VesselTransferPage';
|
||||||
import { VesselTransferApplicationPage } from './features/vessel-transfer/pages/VesselTransferApplicationPage';
|
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([
|
export const router = createBrowserRouter([
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
@@ -88,6 +93,11 @@ export const router = createBrowserRouter([
|
|||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
{ 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
|
// Vessel Ownership Transfer
|
||||||
{ path: '/vessel-transfers', element: <VesselTransferPage /> },
|
{ path: '/vessel-transfers', element: <VesselTransferPage /> },
|
||||||
{ path: '/vessel-transfers/apply', element: <VesselTransferApplicationPage /> },
|
{ path: '/vessel-transfers/apply', element: <VesselTransferApplicationPage /> },
|
||||||
|
|||||||
73
docs/vessel-registration.md
Normal file
73
docs/vessel-registration.md
Normal 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.
|
||||||
Reference in New Issue
Block a user