Files
emaui/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx

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>
);
}