mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 04:48:19 +00:00
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:
@@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal file
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||||
|
|
||||||
|
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
|
||||||
|
|
||||||
|
export const VESSEL_TYPES: Record<VesselCategory, string[]> = {
|
||||||
|
'Inland Waterway': ['Passenger Boat', 'Cargo Barge', 'Ferry', 'Tugboat', 'Fishing Boat'],
|
||||||
|
'Sea-going': ['Bulk Carrier', 'Container Ship', 'Tanker', 'General Cargo', 'Passenger Ship'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENGINE_TYPES = ['Diesel', 'Inboard', 'Outboard', 'Electric', 'Steam'] as const;
|
||||||
|
|
||||||
|
export const HULL_MATERIALS = ['Steel', 'Aluminum', 'Fiberglass', 'Wood', 'Composite'] as const;
|
||||||
|
|
||||||
|
export interface RequiredDocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
minCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REQUIRED_DOCS: Record<VesselCategory, RequiredDocSlot[]> = {
|
||||||
|
'Inland Waterway': [{ key: 'photos', label: 'Vessel Photos', minCount: 2 }],
|
||||||
|
'Sea-going': [
|
||||||
|
{ key: 'photos', label: 'Vessel Photos' },
|
||||||
|
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale' },
|
||||||
|
{ key: 'particulars', label: 'Ship Particulars' },
|
||||||
|
{ key: 'insurance', label: 'Insurance Certificate' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CERTIFICATES: Record<VesselCategory, string[]> = {
|
||||||
|
'Inland Waterway': ['Inland Vessel Registration Certificate'],
|
||||||
|
'Sea-going': [
|
||||||
|
'Certificate of Nationality',
|
||||||
|
'Certificate of Ownership',
|
||||||
|
'Certificate of Registration',
|
||||||
|
'Minimum Safe Manning Certificate',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationStatus = 'Pending' | 'Under Review' | 'Correction Required' | 'Approved' | 'Rejected';
|
||||||
|
|
||||||
|
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
|
||||||
|
Pending: 'gray',
|
||||||
|
'Under Review': 'blue',
|
||||||
|
'Correction Required': 'orange',
|
||||||
|
Approved: 'teal',
|
||||||
|
Rejected: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
|
||||||
|
|
||||||
|
export interface RegistrationOwner {
|
||||||
|
name: string;
|
||||||
|
idOrTin: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationCertificate {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
issueDate: string;
|
||||||
|
downloads: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineStep {
|
||||||
|
date: string | null;
|
||||||
|
event: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselRegistration {
|
||||||
|
id: string;
|
||||||
|
category: VesselCategory;
|
||||||
|
status: RegistrationStatus;
|
||||||
|
submitted: string;
|
||||||
|
remarks?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
renewal?: RenewalState;
|
||||||
|
timeline: TimelineStep[];
|
||||||
|
certificates?: RegistrationCertificate[];
|
||||||
|
|
||||||
|
// Vessel details
|
||||||
|
vesselName: string;
|
||||||
|
vesselType: string;
|
||||||
|
registrationArea: string;
|
||||||
|
flagState: string;
|
||||||
|
passengerCapacity?: string;
|
||||||
|
grossTonnage?: string;
|
||||||
|
length: string;
|
||||||
|
breadth: string;
|
||||||
|
depth: string;
|
||||||
|
|
||||||
|
// Technical
|
||||||
|
imoNumber?: string;
|
||||||
|
hullNumber?: string;
|
||||||
|
shipyard: string;
|
||||||
|
yearBuilt: string;
|
||||||
|
engineType: string;
|
||||||
|
engineNumber: string;
|
||||||
|
enginePower: string;
|
||||||
|
hullMaterial: string;
|
||||||
|
|
||||||
|
// Ownership
|
||||||
|
owner: RegistrationOwner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
|
||||||
|
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
|
||||||
|
|
||||||
|
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
|
||||||
|
{
|
||||||
|
id: 'VR-2025-0001',
|
||||||
|
category: 'Sea-going',
|
||||||
|
status: 'Under Review',
|
||||||
|
submitted: '2025-06-20',
|
||||||
|
timeline: [
|
||||||
|
SUBMITTED_STEP('2025-06-20'),
|
||||||
|
{ date: '2025-06-22', event: 'Document Verification', done: true },
|
||||||
|
PENDING_STEP('Inspection'),
|
||||||
|
PENDING_STEP('Approval'),
|
||||||
|
],
|
||||||
|
vesselName: 'MV Nile Star',
|
||||||
|
vesselType: 'Bulk Carrier',
|
||||||
|
registrationArea: 'Djibouti Corridor',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
grossTonnage: '18500',
|
||||||
|
length: '190',
|
||||||
|
breadth: '28',
|
||||||
|
depth: '15',
|
||||||
|
imoNumber: 'IMO9876543',
|
||||||
|
shipyard: 'Hyundai Heavy Industries',
|
||||||
|
yearBuilt: '2016',
|
||||||
|
engineType: 'Diesel',
|
||||||
|
engineNumber: 'ENG-44210',
|
||||||
|
enginePower: '12000 kW',
|
||||||
|
hullMaterial: 'Steel',
|
||||||
|
owner: {
|
||||||
|
name: 'Nile Shipping PLC',
|
||||||
|
idOrTin: 'TIN-0012345678',
|
||||||
|
phone: '+251911223344',
|
||||||
|
email: 'ops@nileshipping.et',
|
||||||
|
address: 'Bole Sub-city, Addis Ababa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VR-2025-0002',
|
||||||
|
category: 'Inland Waterway',
|
||||||
|
status: 'Correction Required',
|
||||||
|
submitted: '2025-06-10',
|
||||||
|
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
|
||||||
|
timeline: [
|
||||||
|
SUBMITTED_STEP('2025-06-10'),
|
||||||
|
{ date: '2025-06-12', event: 'Document Verification', done: true },
|
||||||
|
PENDING_STEP('Inspection'),
|
||||||
|
PENDING_STEP('Approval'),
|
||||||
|
],
|
||||||
|
vesselName: 'Tana Ferry 3',
|
||||||
|
vesselType: 'Ferry',
|
||||||
|
registrationArea: 'Lake Tana',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
passengerCapacity: '40',
|
||||||
|
length: '18',
|
||||||
|
breadth: '5',
|
||||||
|
depth: '2',
|
||||||
|
hullNumber: 'HN-2211',
|
||||||
|
shipyard: 'Bahir Dar Boat Works',
|
||||||
|
yearBuilt: '2020',
|
||||||
|
engineType: 'Outboard',
|
||||||
|
engineNumber: 'ENG-9931',
|
||||||
|
enginePower: '150 hp',
|
||||||
|
hullMaterial: 'Fiberglass',
|
||||||
|
owner: {
|
||||||
|
name: 'Getachew Alemu',
|
||||||
|
idOrTin: 'ID-4455667788',
|
||||||
|
phone: '+251922334455',
|
||||||
|
email: 'getachew.alemu@example.com',
|
||||||
|
address: 'Bahir Dar, Amhara',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VR-2025-0003',
|
||||||
|
category: 'Sea-going',
|
||||||
|
status: 'Approved',
|
||||||
|
submitted: '2025-04-05',
|
||||||
|
expiryDate: '2026-08-15',
|
||||||
|
renewal: 'Due Soon',
|
||||||
|
timeline: [
|
||||||
|
SUBMITTED_STEP('2025-04-05'),
|
||||||
|
{ date: '2025-04-08', event: 'Document Verification', done: true },
|
||||||
|
{ date: '2025-04-20', event: 'Inspection', done: true },
|
||||||
|
{ date: '2025-04-28', event: 'Approval', done: true },
|
||||||
|
],
|
||||||
|
certificates: [
|
||||||
|
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||||
|
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||||
|
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||||
|
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||||
|
],
|
||||||
|
vesselName: 'MV Abay Voyager',
|
||||||
|
vesselType: 'General Cargo',
|
||||||
|
registrationArea: 'Djibouti Corridor',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
grossTonnage: '9600',
|
||||||
|
length: '140',
|
||||||
|
breadth: '21',
|
||||||
|
depth: '11',
|
||||||
|
imoNumber: 'IMO9123456',
|
||||||
|
shipyard: 'Damen Shipyards',
|
||||||
|
yearBuilt: '2012',
|
||||||
|
engineType: 'Diesel',
|
||||||
|
engineNumber: 'ENG-33012',
|
||||||
|
enginePower: '7200 kW',
|
||||||
|
hullMaterial: 'Steel',
|
||||||
|
owner: {
|
||||||
|
name: 'Abay Maritime PLC',
|
||||||
|
idOrTin: 'TIN-0098765432',
|
||||||
|
phone: '+251933445566',
|
||||||
|
email: 'contact@abaymaritime.et',
|
||||||
|
address: 'Kirkos Sub-city, Addis Ababa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VR-2025-0004',
|
||||||
|
category: 'Inland Waterway',
|
||||||
|
status: 'Rejected',
|
||||||
|
submitted: '2025-03-02',
|
||||||
|
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
|
||||||
|
timeline: [
|
||||||
|
SUBMITTED_STEP('2025-03-02'),
|
||||||
|
{ date: '2025-03-05', event: 'Document Verification', done: true },
|
||||||
|
{ date: '2025-03-14', event: 'Inspection', done: true },
|
||||||
|
{ date: '2025-03-18', event: 'Approval', done: false },
|
||||||
|
],
|
||||||
|
vesselName: 'Awash Cargo 1',
|
||||||
|
vesselType: 'Cargo Barge',
|
||||||
|
registrationArea: 'Awash River Basin',
|
||||||
|
flagState: 'Ethiopia',
|
||||||
|
passengerCapacity: '0',
|
||||||
|
length: '22',
|
||||||
|
breadth: '6',
|
||||||
|
depth: '3',
|
||||||
|
hullNumber: 'HN-1187',
|
||||||
|
shipyard: 'Awash River Works',
|
||||||
|
yearBuilt: '2018',
|
||||||
|
engineType: 'Inboard',
|
||||||
|
engineNumber: 'ENG-5567',
|
||||||
|
enginePower: '210 hp',
|
||||||
|
hullMaterial: 'Steel',
|
||||||
|
owner: {
|
||||||
|
name: 'Selam Tesfaye',
|
||||||
|
idOrTin: 'ID-2233445566',
|
||||||
|
phone: '+251944556677',
|
||||||
|
email: 'selam.tesfaye@example.com',
|
||||||
|
address: 'Adama, Oromia',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function addRegistration(
|
||||||
|
reg: Omit<VesselRegistration, 'id' | 'status' | 'submitted' | 'timeline' | 'certificates'>
|
||||||
|
): VesselRegistration {
|
||||||
|
const submitted = new Date().toISOString().slice(0, 10);
|
||||||
|
const created: VesselRegistration = {
|
||||||
|
...reg,
|
||||||
|
id: `VR-2025-${String(MOCK_REGISTRATIONS.length + 1).padStart(4, '0')}`,
|
||||||
|
status: 'Pending',
|
||||||
|
submitted,
|
||||||
|
timeline: [
|
||||||
|
SUBMITTED_STEP(submitted),
|
||||||
|
PENDING_STEP('Document Verification'),
|
||||||
|
PENDING_STEP('Inspection'),
|
||||||
|
PENDING_STEP('Approval'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
MOCK_REGISTRATIONS.unshift(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: in-memory counter, not persisted.
|
||||||
|
export function recordDownload(regId: string, certName: string): void {
|
||||||
|
const reg = MOCK_REGISTRATIONS.find((r) => r.id === regId);
|
||||||
|
const cert = reg?.certificates?.find((c) => c.name === certName);
|
||||||
|
if (cert) cert.downloads += 1;
|
||||||
|
}
|
||||||
@@ -277,7 +277,9 @@ export function VesselRegistrationPage() {
|
|||||||
{[
|
{[
|
||||||
{ label: 'Submitted', done: true },
|
{ label: 'Submitted', done: true },
|
||||||
{ label: 'Under Review', done: registration.status !== 'Pending' },
|
{ 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) => (
|
].map((step) => (
|
||||||
<Group key={step.label} gap="xs">
|
<Group key={step.label} gap="xs">
|
||||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user