fix(portal): make the restored seafarer/vessel UI compile

Restores three pages the current router imports that Mengestab's branch
never had -- MySeaRecordsPage, VesselTransferPage,
VesselRegistrationStatusPage -- from history, along with the vessel
mock module the status page reads.

Also fixes a dead comparison in VesselRegistrationPage: the block only
renders while a registration is *not* approved, so the nested
`status === 'Approved'` inside it is provably false and TypeScript
refuses it. Rendering behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-16 23:27:27 +03:00
parent eabaae36a0
commit 702496202f
7 changed files with 1545 additions and 1 deletions

View File

@@ -0,0 +1,111 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function seaServiceActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (record: SeaServiceRecord) => void;
onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
}): AdvancedColumn<SeaServiceRecord> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const record = row.original;
const locked = record.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(record)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(record)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(record)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},
};
}
export function medicalActionsColumn(handlers: {
/** Permission check from usePermissions() — hooks can't run in a cell. */
can: (required?: string[]) => boolean;
onEvidence: (certificate: MedicalCertificate) => void;
onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
}): AdvancedColumn<MedicalCertificate> {
return {
header: '',
label: 'Actions',
align: 'right',
cell: ({ row }) => {
const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED';
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence">
<ActionIcon
variant="subtle"
onClick={() => handlers.onEvidence(certificate)}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
<ActionIcon
variant="subtle"
disabled={locked}
onClick={() => handlers.onEdit(certificate)}
>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => handlers.onDelete(certificate)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
);
},
};
}

View File

@@ -0,0 +1,117 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
};
export const FITNESS_OPTIONS = [
{ value: 'FIT', label: 'Fit' },
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' },
{ value: 'UNFIT', label: 'Unfit' },
];
export function seaServiceColumns(
showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] {
return [
{
header: 'Vessel',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.vesselName}
</Text>
{row.original.imoNumber && (
<Text size="xs" c="dimmed">
IMO {row.original.imoNumber}
</Text>
)}
</>
),
},
{ header: 'Rank', accessorKey: 'rank' },
{
header: 'From',
accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate),
},
{
header: 'To',
accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate),
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}
export function medicalColumns(
showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10);
return [
{
header: 'Issuer',
cell: ({ row }) => (
<>
<Text fw={600} size="sm">
{row.original.issuerName}
</Text>
{row.original.certificateNumber && (
<Text size="xs" c="dimmed">
{row.original.certificateNumber}
</Text>
)}
</>
),
},
{
header: 'Issued',
accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate),
},
{
header: 'Expires',
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),
},
{
header: 'Fitness',
cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
?.label ?? row.original.fitnessStatus,
},
{
header: 'Status',
cell: ({ row }) => (
<Tooltip
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status}
</Badge>
</Tooltip>
),
},
];
}

View File

@@ -0,0 +1,637 @@
import {
Alert,
Anchor,
Badge,
Button,
Card,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Tabs,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconFileUpload,
IconInfoCircle,
IconPaperclip,
IconPlus,
IconStethoscope,
} from '@tabler/icons-react';
import { useState } from 'react';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
uploadDocument,
useCreateMedicalCertificateMutation,
useCreateSeaServiceRecordMutation,
useDeleteMedicalCertificateMutation,
useDeleteSeaServiceRecordMutation,
useGetAttachmentsQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeaTimeQuery,
useUpdateMedicalCertificateMutation,
useUpdateSeaServiceRecordMutation,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import {
PORTAL_PERMISSIONS,
RequirePermission,
usePermissions,
} from '@ema-platform/auth';
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
/**
* Evidence viewer/uploader shared by both record kinds.
*
* Files upload against the record itself (`SEA_SERVICE_RECORD` /
* `MEDICAL_CERTIFICATE` owner types), so the officer verifying it later opens
* exactly what the seafarer attached.
*/
function EvidenceModal({
ownerType,
ownerId,
onClose,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE';
ownerId: string | null;
onClose: () => void;
}) {
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [uploading, setUploading] = useState(false);
const upload = async (file: File | null) => {
if (!file || !ownerId) return;
setUploading(true);
const result = await uploadDocument({
ownerType,
ownerId,
documentKey: 'evidence',
file,
});
setUploading(false);
if (result.ok) {
notify.success('Evidence uploaded');
refetch();
} else {
notify.error(result.error);
}
};
const files = (attachments ?? []).flatMap((a) => a.files);
return (
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered>
<Stack>
{isLoading ? (
<Loader size="sm" />
) : files.length === 0 ? (
<Text size="sm" c="dimmed">
No evidence uploaded yet.
</Text>
) : (
files.map((file) => (
<Group key={file.id} gap="xs">
<IconPaperclip size={16} />
{file.url ? (
<Anchor href={file.url} target="_blank" size="sm">
{file.originalName}
</Anchor>
) : (
<Text size="sm">{file.originalName}</Text>
)}
</Group>
))
)}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</RequirePermission>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = {
vesselName: '',
imoNumber: '',
vesselType: '',
flagState: '',
rank: '',
engagementDate: '',
dischargeDate: '',
dutiesDescription: '',
};
function SeaServiceTab() {
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
const { data: seaTime } = useGetMySeaTimeQuery();
const [createRecord, { isLoading: creating }] =
useCreateSeaServiceRecordMutation();
const [updateRecord, { isLoading: updating }] =
useUpdateSeaServiceRecordMutation();
const [deleteRecord] = useDeleteSeaServiceRecordMutation();
const [editing, setEditing] = useState<SeaServiceRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const openCreate = () => {
setEditing(null);
setForm(EMPTY_SEA_SERVICE);
setGrossTonnage('');
setModalOpen(true);
};
const openEdit = (record: SeaServiceRecord) => {
setEditing(record);
setForm({
vesselName: record.vesselName,
imoNumber: record.imoNumber ?? '',
vesselType: record.vesselType ?? '',
flagState: record.flagState ?? '',
rank: record.rank,
engagementDate: record.engagementDate,
dischargeDate: record.dischargeDate,
dutiesDescription: record.dutiesDescription ?? '',
});
setGrossTonnage(record.grossTonnage ? Number(record.grossTonnage) : '');
setModalOpen(true);
};
const save = async () => {
const body = {
vesselName: form.vesselName,
rank: form.rank,
engagementDate: form.engagementDate,
dischargeDate: form.dischargeDate,
...(form.imoNumber ? { imoNumber: form.imoNumber } : {}),
...(form.vesselType ? { vesselType: form.vesselType } : {}),
...(form.flagState ? { flagState: form.flagState } : {}),
...(form.dutiesDescription
? { dutiesDescription: form.dutiesDescription }
: {}),
...(grossTonnage !== '' ? { grossTonnage: Number(grossTonnage) } : {}),
};
try {
if (editing) {
await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else {
await createRecord(body).unwrap();
notify.success('Sea-service record added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record'));
}
};
const remove = async (record: SeaServiceRecord) => {
try {
await deleteRecord(record.id).unwrap();
notify.success('Record withdrawn');
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not delete the record'));
}
};
const valid =
form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 &&
form.engagementDate &&
form.dischargeDate &&
form.engagementDate < form.dischargeDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(records ?? []);
const columns = [
...seaServiceColumns(showDate),
seaServiceActionsColumn({
can,
onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (
<Stack>
<Group justify="space-between">
<Group gap="sm">
<Text size="sm" c="dimmed">
Every engagement aboard a vessel, with its evidence. Verified
records feed certificate eligibility.
</Text>
{seaTime && seaTime.verifiedRecords > 0 && (
<Badge variant="light" color="teal">
Approved sea time: {seaTime.totalDays} days
</Badge>
)}
</Group>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service
</Button>
</RequirePermission>
</Group>
{(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No sea-service records yet.
</Text>
</Paper>
) : (
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Sea service"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Vessel name"
required
value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/>
<TextInput
label="IMO number"
value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/>
</Group>
<Group grow>
<TextInput
label="Vessel type"
value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/>
<TextInput
label="Flag state"
value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/>
<NumberInput
label="Gross tonnage"
min={0}
value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/>
</Group>
<TextInput
label="Rank / capacity"
required
value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })}
/>
<Group grow>
<AmharicDatePicker
label="Engagement date"
required
value={form.engagementDate}
onChange={(val) =>
setForm({ ...form, engagementDate: val })
}
dateFormat="date"
/>
<AmharicDatePicker
label="Discharge date"
required
value={form.dischargeDate}
onChange={(val) =>
setForm({ ...form, dischargeDate: val })
}
dateFormat="date"
/>
</Group>
<Textarea
label="Duties"
value={form.dutiesDescription}
onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value })
}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add record'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="SEA_SERVICE_RECORD"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
// -------------------------------------------------------------------- medical
const EMPTY_MEDICAL = {
issuerName: '',
certificateNumber: '',
issueDate: '',
expiryDate: '',
fitnessStatus: 'FIT',
restrictions: '',
};
function MedicalTab() {
const showDate = useDateDisplayer();
const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
const [createCertificate, { isLoading: creating }] =
useCreateMedicalCertificateMutation();
const [updateCertificate, { isLoading: updating }] =
useUpdateMedicalCertificateMutation();
const [deleteCertificate] = useDeleteMedicalCertificateMutation();
const [editing, setEditing] = useState<MedicalCertificate | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
const [form, setForm] = useState(EMPTY_MEDICAL);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_MEDICAL);
setModalOpen(true);
};
const openEdit = (certificate: MedicalCertificate) => {
setEditing(certificate);
setForm({
issuerName: certificate.issuerName,
certificateNumber: certificate.certificateNumber ?? '',
issueDate: certificate.issueDate,
expiryDate: certificate.expiryDate,
fitnessStatus: certificate.fitnessStatus,
restrictions: certificate.restrictions ?? '',
});
setModalOpen(true);
};
const save = async () => {
const body = {
issuerName: form.issuerName,
issueDate: form.issueDate,
expiryDate: form.expiryDate,
fitnessStatus: form.fitnessStatus as MedicalCertificate['fitnessStatus'],
...(form.certificateNumber
? { certificateNumber: form.certificateNumber }
: {}),
...(form.restrictions ? { restrictions: form.restrictions } : {}),
};
try {
if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else {
await createCertificate(body).unwrap();
notify.success('Medical certificate added');
}
setModalOpen(false);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate'));
}
};
const remove = async (certificate: MedicalCertificate) => {
try {
await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn');
} catch (error) {
notify.error(
extractErrorMessage(error, 'Could not delete the certificate'),
);
}
};
const valid =
form.issuerName.trim().length > 1 &&
form.issueDate &&
form.expiryDate &&
form.issueDate < form.expiryDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const page = paginate(certificates ?? []);
const columns = [
...medicalColumns(showDate),
medicalActionsColumn({
can,
onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit,
onDelete: remove,
}),
];
return (
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new
applications that require one.
</Text>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate
</Button>
</RequirePermission>
</Group>
{(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center">
No medical certificates yet.
</Text>
</Paper>
) : (
<Card withBorder padding={0}>
<AdvancedTable
columns={columns}
data={page.rows}
tableName="Medical certificates"
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
isLoading={isLoading}
refresh={refetch}
/>
</Card>
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'}
centered
size="lg"
>
<Stack>
<Group grow>
<TextInput
label="Issuing clinic / physician"
required
value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/>
<TextInput
label="Certificate number"
value={form.certificateNumber}
onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value })
}
/>
</Group>
<Group grow>
<AmharicDatePicker
label="Issue date"
required
value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })}
dateFormat="date"
/>
<AmharicDatePicker
label="Expiry date"
required
value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })}
dateFormat="date"
/>
</Group>
<Select
label="Fitness outcome"
data={FITNESS_OPTIONS}
value={form.fitnessStatus}
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
/>
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
<Textarea
label="Restrictions"
value={form.restrictions}
onChange={(e) =>
setForm({ ...form, restrictions: e.target.value })
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={save}
disabled={!valid}
loading={creating || updating}
>
{editing ? 'Save changes' : 'Add certificate'}
</Button>
</Group>
</Stack>
</Modal>
<EvidenceModal
ownerType="MEDICAL_CERTIFICATE"
ownerId={evidenceFor}
onClose={() => setEvidenceFor(null)}
/>
</Stack>
);
}
/**
* The seafarer's evidence shelf (US-SSM-001/006): sea-service history and
* medical certificates, each with uploaded evidence, editable until an
* officer verifies them.
*/
export function MySeaRecordsPage() {
return (
<Stack>
<Title order={2}>My Sea Records</Title>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={16} />}
>
Records you add here are submitted for EMA verification. Once verified
they are frozen and count toward certificate eligibility.
</Alert>
<Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical Certificates
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sea-service" pt="md">
<SeaServiceTab />
</Tabs.Panel>
<Tabs.Panel value="medical" pt="md">
<MedicalTab />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

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

View File

@@ -277,7 +277,9 @@ export function VesselRegistrationPage() {
{[
{ label: 'Submitted', done: true },
{ label: 'Under Review', done: registration.status !== 'Pending' },
{ label: 'Approved', done: registration.status === 'Approved' },
// 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'}>

View File

@@ -0,0 +1,158 @@
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 { useDateDisplayer } from '@ema-platform/shared';
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 showDate = useDateDisplayer();
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 ${showDate(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 {showDate(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>
);
}

View File

@@ -0,0 +1,233 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Group,
Loader,
Paper,
Progress,
Stack,
Table,
Text,
Title,
Tooltip,
} from '@mantine/core';
import {
IconArrowRight,
IconArrowsExchange,
IconShip,
} from '@tabler/icons-react';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
useGetMyApplicationsQuery,
useGetMyVesselsQuery,
} from '@ema-platform/api';
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
};
/**
* The vessel owner's ownership-transfer home: transfers in flight, and the
* registered vessels eligible to start one. Same config-driven wizard as
* registration underneath (VESSEL_OWNERSHIP_TRANSFER) — this page mirrors
* VesselRegistrationPage, just a different entry point and no
* certificate/renewal/incident actions, which don't apply here.
*/
export function VesselTransferPage() {
const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === TRANSFER_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
// A transfer moves ownership of a vessel already on the register — nothing
// to transfer without at least one REGISTERED vessel.
const hasTransferableVessel = (vessels ?? []).some(
(v) => v.status === 'REGISTERED',
);
if (loadingVessels || loadingApplications) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Ownership Transfer</Title>
<Tooltip
label="Register a vessel first — there's nothing to transfer yet"
disabled={hasTransferableVessel}
>
<Button
leftSection={<IconArrowsExchange size={16} />}
disabled={!hasTransferableVessel}
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
>
Start transfer
</Button>
</Tooltip>
</Group>
{/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Title order={4}>Transfers in progress</Title>
{inFlight.map((app) => {
const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED';
return (
<Card key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={600}>{app.applicationNumber}</Text>
<Progress
value={STATUS_PROGRESS[app.status]}
mt="xs"
w={260}
/>
</div>
<Group wrap="nowrap">
<Badge color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
<Button
size="compact-sm"
variant={needsAction ? 'filled' : 'light'}
color={needsAction ? 'orange' : undefined}
rightSection={<IconArrowRight size={14} />}
onClick={() =>
navigate(
`/licensing/${TRANSFER_TYPE_KEY}/applications/${app.id}`,
)
}
>
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
</Button>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
{/* ------------------------------------------------------- vessels */}
<Stack gap="sm">
<Title order={4}>My vessels</Title>
{(vessels ?? []).length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Stack align="center" gap="sm">
<IconShip size={40} color="var(--mantine-color-blue-5)" />
<Text fw={600}>No registered vessels yet</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
Ownership can only be transferred for a vessel already on the
register.
</Text>
<Button
mt="xs"
variant="light"
onClick={() => navigate('/vessel-registration')}
>
Go to Vessel Registration
</Button>
</Stack>
</Paper>
) : (
<Table.ScrollContainer minWidth={640}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration </Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vessels ?? []).map((vessel) => {
const canTransfer = vessel.status === 'REGISTERED';
return (
<Table.Tr key={vessel.id}>
<Table.Td>
<Text ff="monospace" size="sm" fw={600}>
{vessel.registrationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{vessel.name}
</Text>
<Text size="xs" c="dimmed">
{vessel.vesselType ?? '—'}
</Text>
</Table.Td>
<Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end">
{canTransfer ? (
<Tooltip label="Start an ownership transfer for this vessel">
<Button
size="compact-xs"
variant="light"
leftSection={<IconArrowsExchange size={14} />}
onClick={() =>
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
}
>
Transfer
</Button>
</Tooltip>
) : (
<Text size="xs" c="dimmed">
Not transferable
</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
</Stack>
);
}
export default VesselTransferPage;