mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 18:48:12 +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: 'የመርከበኞች መጽሐፍ ወረፋ',
|
||||
cocQueue: 'የCoC/CoP ወረፋ',
|
||||
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
||||
applications: 'ማመልከቻዎች',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const en = {
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselRegistrationReport: 'Registration Report',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
applications: 'Applications',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconCreditCard,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
IconReportAnalytics,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -34,6 +36,8 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||
{ to: '/vessel-registrations', label: 'nav.vesselRegistrations', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconReportAnalytics },
|
||||
{ to: '/vessel-transfers', label: 'nav.vesselTransfers', icon: IconShip },
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||
|
||||
@@ -32,6 +32,9 @@ import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
|
||||
import { ResultPage } from '../features/result/pages/ResultPage';
|
||||
import { VesselTransferQueuePage } from '../features/vessel-transfer/pages/VesselTransferQueuePage';
|
||||
import { VesselTransferReviewPage } from '../features/vessel-transfer/pages/VesselTransferReviewPage';
|
||||
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -66,6 +69,9 @@ const router = createBrowserRouter([
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
|
||||
{ path: 'vessel-registrations', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'vessel-registrations/:id', element: <VesselRegistrationReviewPage /> },
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-transfers', element: <VesselTransferQueuePage /> },
|
||||
{ path: 'vessel-transfers/:id', element: <VesselTransferReviewPage /> },
|
||||
{ path: 'questions', element: <QuestionPage /> },
|
||||
|
||||
Reference in New Issue
Block a user