mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { type StatusTone } from '@ema-platform/shared';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Paper,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAnchor,
|
|
IconCheck,
|
|
IconCircleCheck,
|
|
IconAlertCircle,
|
|
IconShieldCheck,
|
|
IconCertificate,
|
|
IconDownload,
|
|
IconInfoCircle,
|
|
IconClockHour4,
|
|
IconTransferIn,
|
|
} from '@tabler/icons-react';
|
|
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
|
|
import { inFlightColumns } from '../inFlightColumns';
|
|
import {
|
|
extractErrorMessage,
|
|
TERMINAL_STATUSES,
|
|
useApiMutation,
|
|
useBypassPaymentMutation,
|
|
useGetMyApplicationsQuery,
|
|
} from '@ema-platform/api';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { authStorage } from '@ema-platform/auth';
|
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
/** Registration applications run through the config-driven licensing wizard. */
|
|
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
|
|
|
|
const PAGE_SIZE = 5;
|
|
|
|
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
|
|
type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
|
|
type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
|
|
|
|
interface VesselRegistration {
|
|
id: string;
|
|
vesselName: string;
|
|
category: VesselCategory;
|
|
vesselType: string;
|
|
flagState: string;
|
|
portOfRegistry: string;
|
|
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
|
|
capacityValue: number;
|
|
vesselLengthM: number;
|
|
imoOrHullNumber: string;
|
|
ownerName: string;
|
|
status: VesselRegStatus;
|
|
submittedDate: string;
|
|
approvalDate: string | null;
|
|
remarks: string;
|
|
renewalStatus: RenewalStatus;
|
|
expiryDate: string | null;
|
|
}
|
|
|
|
const STATUS_TONE: Record<string, StatusTone> = {
|
|
Pending: 'neutral',
|
|
'Under Review': 'warning',
|
|
Approved: 'success',
|
|
Rejected: 'danger',
|
|
'Correction Required': 'pending',
|
|
};
|
|
|
|
// Inland vessel certificates (1)
|
|
const INLAND_CERTIFICATES = [
|
|
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
|
|
];
|
|
|
|
// Sea-going vessel certificates (4)
|
|
const SEAGOING_CERTIFICATES = [
|
|
{ label: 'Certificate of Nationality', description: 'Certifies the vessel\'s nationality and right to fly the Ethiopian flag' },
|
|
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
|
|
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
|
|
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Requirements list
|
|
// ---------------------------------------------------------------------------
|
|
function RequirementItem({ label }: { label: string }) {
|
|
return (
|
|
<Group gap="xs">
|
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light">
|
|
<IconCheck size={12} />
|
|
</ThemeIcon>
|
|
<Text fz="sm">{label}</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Certificate card (shown after approval)
|
|
// ---------------------------------------------------------------------------
|
|
function CertificateCard({ label, description }: { label: string; description: string }) {
|
|
return (
|
|
<Card withBorder radius="md" p="md">
|
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
|
<ThemeIcon size={36} radius="md" color="teal" variant="light">
|
|
<IconCertificate size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={600} fz="sm">{label}</Text>
|
|
<Text fz="xs" c="dimmed">{description}</Text>
|
|
</div>
|
|
</Group>
|
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
|
Download Certificate
|
|
</Button>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
export function VesselRegistrationPage() {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
|
|
const { pay, isPaying } = useApplicationPayment();
|
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
|
const [page, setPage] = useState(0);
|
|
const [registration, setRegistration] = useState<VesselRegistration | null>(null);
|
|
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
|
const fetched = useRef(false);
|
|
|
|
useEffect(() => {
|
|
const profileId = authStorage.getProfileId();
|
|
if (!profileId || fetched.current) return;
|
|
fetched.current = true;
|
|
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
|
.unwrap()
|
|
.then((data) => setRegistration(data))
|
|
.catch(() => {/* no registration yet */});
|
|
}, [fetchTrigger]);
|
|
|
|
// Drafts and anything still moving through review — the applicant's own
|
|
// registration applications, straight from the licensing queue.
|
|
const inFlight = (applications?.items ?? []).filter(
|
|
(app) =>
|
|
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
|
|
!TERMINAL_STATUSES.includes(app.status),
|
|
);
|
|
|
|
async function handleBypass(applicationId: string) {
|
|
try {
|
|
const result = await bypassPayment(applicationId).unwrap();
|
|
notifications.show({
|
|
color: 'teal',
|
|
title: 'Payment bypassed',
|
|
message: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
|
});
|
|
refetch();
|
|
} catch (err) {
|
|
notifications.show({
|
|
color: 'red',
|
|
title: 'Bypass failed',
|
|
message: extractErrorMessage(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
const columns = inFlightColumns(t, {
|
|
onOpen: (app) =>
|
|
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
|
|
// Paying leaves the SPA for Telebirr — a provider hand-off, not a route change.
|
|
onPay: (app) => pay(app.id),
|
|
onBypass: (app) => handleBypass(app.id),
|
|
isPaying,
|
|
bypassing,
|
|
});
|
|
|
|
const certs = registration?.category === 'Sea-going Vessel (International)'
|
|
? SEAGOING_CERTIFICATES
|
|
: INLAND_CERTIFICATES;
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group gap="sm">
|
|
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
|
<IconAnchor size={24} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Title order={3}>Vessel Registration</Title>
|
|
<Text fz="sm" c="dimmed">Register your vessel with the Ethiopian Maritime Authority</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
{/* ── Drafts / submitted applications ───────────────────────────── */}
|
|
{inFlight.length > 0 && (
|
|
<Stack gap="sm">
|
|
<Text fw={700} fz="md">{t('vesselRegistration.inFlight.title')}</Text>
|
|
<AdvancedTable
|
|
tableName="vessel-registration-in-flight"
|
|
columns={columns}
|
|
// Client-side paging: getMyApplications returns the whole list.
|
|
data={inFlight.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
|
|
itemCount={inFlight.length}
|
|
pageIndex={page}
|
|
pageSize={PAGE_SIZE}
|
|
refresh={refetch}
|
|
isLoading={isFetching}
|
|
onPageChange={setPage}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
|
|
{/* ── No registration yet ───────────────────────────────────────── */}
|
|
{!registration && (
|
|
<>
|
|
<Paper withBorder radius="lg" p="xl">
|
|
<Group gap="md" mb="lg" wrap="nowrap">
|
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light">
|
|
<IconAnchor size={28} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700} fz="lg">Register Your Vessel</Text>
|
|
<Text fz="sm" c="dimmed">
|
|
Obtain official registration for inland waterway or sea-going vessels
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
|
|
<Divider mb="md" />
|
|
|
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
|
<Stack gap={6} mb="xl">
|
|
<RequirementItem label="Proof of Ownership / Bill of Sale" />
|
|
<RequirementItem label="Builder's Certificate or Technical Specifications" />
|
|
<RequirementItem label="Valid Insurance Certificate (Hull & Machinery)" />
|
|
<RequirementItem label="Tax Clearance Certificate" />
|
|
<RequirementItem label="Vessel Photos (at least 2 clear images)" />
|
|
<RequirementItem label="IMO Certificate of Registry (sea-going re-registration only)" />
|
|
</Stack>
|
|
|
|
<Button
|
|
size="md"
|
|
leftSection={<IconAnchor size={18} />}
|
|
onClick={() => navigate('/vessel-registration/apply')}
|
|
>
|
|
Start Vessel Registration
|
|
</Button>
|
|
</Paper>
|
|
|
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
|
<Group gap="xs" mb={4}>
|
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
|
<Text fw={600} fz="sm" c="blue.7">About Vessel Registration</Text>
|
|
</Group>
|
|
<Text fz="sm" c="dimmed">
|
|
Registration is valid for <strong>5 years</strong> from the date of approval. After approval,
|
|
inland vessels receive an <strong>Inland Vessel Registration Certificate</strong>, while
|
|
sea-going vessels receive four certificates: Certificate of Nationality, Certificate of
|
|
Ownership, Certificate of Registration, and Minimum Safe Manning Certificate.
|
|
</Text>
|
|
</Paper>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Registration exists ──────────────────────────────────────── */}
|
|
{registration && (
|
|
<>
|
|
{/* Renewal alert */}
|
|
{registration.renewalStatus === 'Due Soon' && (
|
|
<Alert
|
|
icon={<IconAlertCircle size={17} />}
|
|
color="orange"
|
|
title="Renewal Due Soon"
|
|
>
|
|
Your vessel registration expires on {registration.expiryDate}. Please initiate renewal to avoid expiry.
|
|
<Button size="xs" variant="white" color="orange" mt="xs">
|
|
Start Renewal
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
{registration.renewalStatus === 'Overdue' && (
|
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Registration Expired">
|
|
Your vessel registration expired on {registration.expiryDate}. Immediate renewal is required.
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Status card */}
|
|
<Paper withBorder radius="lg" p="xl">
|
|
<Group justify="space-between" mb="lg">
|
|
<Group gap="sm">
|
|
<ThemeIcon size={40} radius="md" color="blue" variant="light">
|
|
<IconAnchor size={22} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fw={700} fz="lg">{registration.vesselName}</Text>
|
|
<Text fz="xs" c="dimmed">{registration.id}</Text>
|
|
</div>
|
|
</Group>
|
|
<StatusBadge
|
|
tone={STATUS_TONE[registration.status] ?? 'neutral'}
|
|
label={registration.status}
|
|
size="lg"
|
|
variant="light"
|
|
/>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
|
{[
|
|
{ label: 'Category', value: registration.category },
|
|
{ label: 'Vessel Type', value: registration.vesselType },
|
|
{ label: 'Flag State', value: registration.flagState },
|
|
{ label: 'Port of Registry', value: registration.portOfRegistry },
|
|
{ label: registration.capacityLabel, value: String(registration.capacityValue) },
|
|
{ label: 'Submitted', value: registration.submittedDate },
|
|
].map((row) => (
|
|
<div key={row.label}>
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{row.label}</Text>
|
|
<Text fz="sm" mt={2}>{row.value || '—'}</Text>
|
|
</div>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{registration.remarks && (
|
|
<>
|
|
<Divider my="md" />
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
|
<Text fz="sm">{registration.remarks}</Text>
|
|
</>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Timeline / status info */}
|
|
{registration.status !== 'Approved' && (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group gap="xs" mb="sm">
|
|
<IconClockHour4 size={16} />
|
|
<Text fw={600} fz="sm">Application Status</Text>
|
|
</Group>
|
|
<Stack gap={6}>
|
|
{[
|
|
{ label: 'Submitted', done: true },
|
|
{ label: 'Under Review', done: registration.status !== 'Pending' },
|
|
// Always pending here: this whole block only renders while the
|
|
// registration is *not* approved, so the step cannot be done.
|
|
{ label: 'Approved', done: false },
|
|
].map((step) => (
|
|
<Group key={step.label} gap="xs">
|
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
|
<IconCheck size={12} />
|
|
</ThemeIcon>
|
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Transfer ownership — only when approved */}
|
|
{registration.status === 'Approved' && (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group justify="space-between">
|
|
<div>
|
|
<Text fw={600} fz="sm">Transfer Ownership</Text>
|
|
<Text fz="xs" c="dimmed">Transfer this vessel to a new owner</Text>
|
|
</div>
|
|
<Button
|
|
leftSection={<IconTransferIn size={15} />}
|
|
color="violet"
|
|
variant="light"
|
|
size="sm"
|
|
onClick={() => navigate('/vessel-registration/transfer')}
|
|
>
|
|
Request Transfer
|
|
</Button>
|
|
</Group>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Certificates section — shown after approval */}
|
|
{registration.status === 'Approved' && (
|
|
<div>
|
|
<Group gap="xs" mb="sm">
|
|
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
|
|
<Text fw={700} fz="md">
|
|
{registration.category === 'Sea-going Vessel (International)'
|
|
? 'Issued Certificates (4)'
|
|
: 'Issued Certificate'}
|
|
</Text>
|
|
</Group>
|
|
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
|
Your vessel registration has been approved. You may download your certificate(s) below.
|
|
</Alert>
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
{certs.map((cert) => (
|
|
<CertificateCard key={cert.label} label={cert.label} description={cert.description} />
|
|
))}
|
|
</SimpleGrid>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|