mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +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: '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'}>
|
||||
|
||||
@@ -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