mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: Refactor medical and sea service verification pages
- Split MedicalVerificationPage into MedicalVerificationPage and SeaServiceVerificationPage for better separation of concerns. - Update navigation to include separate entries for Sea Service Verification and Medical Verification. - Enhance sea service columns to display additional vessel information and days served. - Add translations for new and updated labels in both English and Amharic. - Introduce seaServiceDays helper function to calculate days served based on engagement and discharge dates. - Remove deprecated MedicalCertificatePage from the portal. - Update routing to direct to the new sea service and medical pages.
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { Badge, Text } from '@mantine/core';
|
import { Badge, Text } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type {
|
import {
|
||||||
MedicalCertificate,
|
seaServiceDays,
|
||||||
SeaServiceRecord,
|
type MedicalCertificate,
|
||||||
SeafarerProfileSummary,
|
type SeaServiceRecord,
|
||||||
SeafarerRecordStatus,
|
type SeafarerProfileSummary,
|
||||||
|
type SeafarerRecordStatus,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
export function ownerName(profile?: SeafarerProfileSummary): string {
|
export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||||
@@ -133,6 +134,17 @@ export function seaServiceColumns(
|
|||||||
IMO {row.original.imoNumber}
|
IMO {row.original.imoNumber}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{[
|
||||||
|
row.original.vesselType,
|
||||||
|
row.original.flagState,
|
||||||
|
row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -151,6 +163,16 @@ export function seaServiceColumns(
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t('recordVerification.columns.days', 'Days'),
|
||||||
|
label: t('recordVerification.columns.days', 'Days'),
|
||||||
|
align: 'right',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
statusColumn<SeaServiceRecord>(t),
|
statusColumn<SeaServiceRecord>(t),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,11 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||||
IconAnchor,
|
|
||||||
IconEye,
|
|
||||||
IconInbox,
|
|
||||||
IconPaperclip,
|
|
||||||
IconStethoscope,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import {
|
import {
|
||||||
AdvancedTable,
|
AdvancedTable,
|
||||||
notify,
|
notify,
|
||||||
@@ -196,7 +189,14 @@ function AttachmentsModal({
|
|||||||
* freezes a record — sea service starts counting toward sea time, a medical
|
* freezes a record — sea service starts counting toward sea time, a medical
|
||||||
* certificate starts satisfying the submission gate.
|
* certificate starts satisfying the submission gate.
|
||||||
*/
|
*/
|
||||||
export function MedicalVerificationPage() {
|
export type VerificationKind = 'medical' | 'sea-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind per page — the sidebar lists "Sea Service Verification" and
|
||||||
|
* "Medical Verification" separately, so an officer lands on the queue they
|
||||||
|
* came for rather than on a tab.
|
||||||
|
*/
|
||||||
|
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||||
const {
|
const {
|
||||||
@@ -372,74 +372,62 @@ export function MedicalVerificationPage() {
|
|||||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isMedical = kind === 'medical';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb={4}>
|
<Title order={3} mb={4}>
|
||||||
{t('recordVerification.title', 'Record verification')}
|
{isMedical
|
||||||
|
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||||
|
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
{t(
|
{isMedical
|
||||||
'recordVerification.subtitle',
|
? t(
|
||||||
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
'recordVerification.medicalSubtitle',
|
||||||
)}
|
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'recordVerification.seaServiceSubtitle',
|
||||||
|
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Tabs defaultValue="medical" keepMounted={false}>
|
{isMedical ? (
|
||||||
<Tabs.List>
|
<AdvancedTable
|
||||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
columns={medicalTableColumns}
|
||||||
{t('recordVerification.tabs.medical', {
|
data={pagedMedical}
|
||||||
count: pendingMedicalList.length,
|
tableName={t('recordVerification.tabs.medical', {
|
||||||
defaultValue: 'Medical ({{count}})',
|
count: pendingMedicalList.length,
|
||||||
})}
|
defaultValue: 'Medical ({{count}})',
|
||||||
</Tabs.Tab>
|
})}
|
||||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
itemCount={pendingMedicalList.length}
|
||||||
{t('recordVerification.tabs.seaService', {
|
pageIndex={medicalPage}
|
||||||
count: pendingSeaServiceList.length,
|
onPageChange={setMedicalPage}
|
||||||
defaultValue: 'Sea Service ({{count}})',
|
pageSize={medicalPageSize}
|
||||||
})}
|
onPageSizeChange={handleMedicalPageSizeChange}
|
||||||
</Tabs.Tab>
|
refresh={refetchMedical}
|
||||||
</Tabs.List>
|
isLoading={loadingMedical || fetchingMedical}
|
||||||
|
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||||
<Tabs.Panel value="medical" pt="md">
|
/>
|
||||||
{statusFilter}
|
) : (
|
||||||
<AdvancedTable
|
<AdvancedTable
|
||||||
columns={medicalTableColumns}
|
columns={seaServiceTableColumns}
|
||||||
data={pagedMedical}
|
data={pagedSeaService}
|
||||||
tableName={t('recordVerification.tabs.medical', {
|
tableName={t('recordVerification.tabs.seaService', {
|
||||||
count: pendingMedicalList.length,
|
count: pendingSeaServiceList.length,
|
||||||
defaultValue: 'Medical ({{count}})',
|
defaultValue: 'Sea Service ({{count}})',
|
||||||
})}
|
})}
|
||||||
itemCount={pendingMedicalList.length}
|
itemCount={pendingSeaServiceList.length}
|
||||||
pageIndex={medicalPage}
|
pageIndex={seaServicePage}
|
||||||
onPageChange={setMedicalPage}
|
onPageChange={setSeaServicePage}
|
||||||
pageSize={medicalPageSize}
|
pageSize={seaServicePageSize}
|
||||||
onPageSizeChange={handleMedicalPageSizeChange}
|
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||||
refresh={refetchMedical}
|
refresh={refetchSeaService}
|
||||||
isLoading={loadingMedical || fetchingMedical}
|
isLoading={loadingSeaService || fetchingSeaService}
|
||||||
emptyText={emptyText}
|
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
)}
|
||||||
|
|
||||||
<Tabs.Panel value="sea-service" pt="md">
|
|
||||||
{statusFilter}
|
|
||||||
<AdvancedTable
|
|
||||||
columns={seaServiceTableColumns}
|
|
||||||
data={pagedSeaService}
|
|
||||||
tableName={t('recordVerification.tabs.seaService', {
|
|
||||||
count: pendingSeaServiceList.length,
|
|
||||||
defaultValue: 'Sea Service ({{count}})',
|
|
||||||
})}
|
|
||||||
itemCount={pendingSeaServiceList.length}
|
|
||||||
pageIndex={seaServicePage}
|
|
||||||
onPageChange={setSeaServicePage}
|
|
||||||
pageSize={seaServicePageSize}
|
|
||||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
|
||||||
refresh={refetchSeaService}
|
|
||||||
isLoading={loadingSeaService || fetchingSeaService}
|
|
||||||
emptyText={emptyText}
|
|
||||||
/>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<AttachmentsModal
|
<AttachmentsModal
|
||||||
opened={Boolean(attachmentModal)}
|
opened={Boolean(attachmentModal)}
|
||||||
@@ -491,4 +479,6 @@ export function MedicalVerificationPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
|
||||||
|
|
||||||
export default MedicalVerificationPage;
|
export default MedicalVerificationPage;
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export const am: Translations = {
|
|||||||
applications: "ማመልከቻዎች",
|
applications: "ማመልከቻዎች",
|
||||||
paymentConfig: "የክፍያ ውቅረት",
|
paymentConfig: "የክፍያ ውቅረት",
|
||||||
analytics: "ትንታኔ",
|
analytics: "ትንታኔ",
|
||||||
medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ",
|
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||||
|
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||||
locations: "አካባቢዎች",
|
locations: "አካባቢዎች",
|
||||||
configuration: "ውቅረት",
|
configuration: "ውቅረት",
|
||||||
profile: "መገለጫ",
|
profile: "መገለጫ",
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ export const en = {
|
|||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
analytics: 'Analytics',
|
analytics: 'Analytics',
|
||||||
medicalVerification: 'Medical and Sea Service Verification',
|
seaServiceVerification: 'Sea Service Verification',
|
||||||
|
medicalVerification: 'Medical Verification',
|
||||||
locations: 'Locations',
|
locations: 'Locations',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
|
||||||
|
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
|||||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
import {
|
||||||
|
MedicalVerificationPage,
|
||||||
|
SeaServiceVerificationPage,
|
||||||
|
} from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||||
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
|
||||||
@@ -85,6 +88,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
|
||||||
|
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||||
// Seafarer registration is not a licence: own queue, own review.
|
// Seafarer registration is not a licence: own queue, own review.
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
import { useRef, useState } from 'react';
|
|
||||||
import { useApiQuery } from '@ema-platform/api';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Progress,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
|
||||||
Timeline,
|
|
||||||
Title,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconAlertCircle,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconCalendar,
|
|
||||||
IconCheck,
|
|
||||||
IconCircleCheck,
|
|
||||||
IconDownload,
|
|
||||||
IconFileDescription,
|
|
||||||
IconHeart,
|
|
||||||
IconInfoCircle,
|
|
||||||
IconTrash,
|
|
||||||
IconUpload,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface MedicalCert {
|
|
||||||
id: string;
|
|
||||||
issuedBy: string;
|
|
||||||
issuedDate: string;
|
|
||||||
expiryDate: string;
|
|
||||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
|
|
||||||
restrictions: string;
|
|
||||||
/** Days left, computed server-side so every screen agrees on the date. */
|
|
||||||
daysRemaining?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The medical card's whole state, as `/medical/my` returns it. */
|
|
||||||
interface MedicalOverview {
|
|
||||||
current: MedicalCert | null;
|
|
||||||
history: MedicalCert[];
|
|
||||||
warningDays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
|
||||||
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
|
|
||||||
};
|
|
||||||
|
|
||||||
function daysUntil(dateStr: string): number {
|
|
||||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export function MedicalCertificatePage() {
|
|
||||||
// The card's whole state comes from one call: the current certificate, the
|
|
||||||
// ones before it, and the validity the server computed. Deriving "expiring"
|
|
||||||
// in the browser would let a wrong client clock disagree with the gate that
|
|
||||||
// blocks an application.
|
|
||||||
const { data: medical } = useApiQuery<MedicalOverview>({
|
|
||||||
url: '/medical/my',
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
const current = medical?.current ?? null;
|
|
||||||
const history = medical?.history ?? [];
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
|
||||||
const [doctorName, setDoctorName] = useState('');
|
|
||||||
const [issuedDate, setIssuedDate] = useState('');
|
|
||||||
const [expiryDate, setExpiryDate] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const resetRef = useRef<() => void>(null);
|
|
||||||
|
|
||||||
// Server's count where it gave one: it is the same figure the eligibility
|
|
||||||
// gate uses, and a browser clock that is wrong or in another timezone would
|
|
||||||
// otherwise show a different number than the officer sees.
|
|
||||||
const days = current
|
|
||||||
? (current.daysRemaining ?? daysUntil(current.expiryDate))
|
|
||||||
: 0;
|
|
||||||
const progressVal = current
|
|
||||||
? Math.max(0, Math.min(100, (days / 730) * 100))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!uploadedFile || !issuedDate || !expiryDate) {
|
|
||||||
notify.error('Please fill all fields and upload the certificate file.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
await new Promise((r) => setTimeout(r, 1200));
|
|
||||||
setSubmitting(false);
|
|
||||||
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
|
|
||||||
setUploadedFile(null);
|
|
||||||
setDoctorName('');
|
|
||||||
setIssuedDate('');
|
|
||||||
setExpiryDate('');
|
|
||||||
resetRef.current?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<Title order={3}>Medical Certificate</Title>
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validity alert */}
|
|
||||||
{current && days <= 90 && days > 0 && (
|
|
||||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
|
|
||||||
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
|
|
||||||
Please visit an EMA-approved medical centre and upload your renewed certificate below.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{current && days <= 0 && (
|
|
||||||
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
|
|
||||||
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!current && (
|
|
||||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
|
||||||
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
{/* Current certificate */}
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Group mb="md" gap="xs">
|
|
||||||
<ThemeIcon variant="light" color="red" size={36} radius="md">
|
|
||||||
<IconHeart size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700}>Current Certificate</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{current ? (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
|
||||||
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
|
|
||||||
<Text fz="sm">{current.id}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
|
|
||||||
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
|
|
||||||
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
|
|
||||||
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
|
|
||||||
{formatDate(current.expiryDate)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
|
|
||||||
<Text fz="sm">{current.restrictions}</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Validity bar */}
|
|
||||||
<Box mt="xs">
|
|
||||||
<Group justify="space-between" mb={4}>
|
|
||||||
<Text fz="xs" c="dimmed">Validity remaining</Text>
|
|
||||||
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
|
|
||||||
</Group>
|
|
||||||
<Progress
|
|
||||||
value={progressVal}
|
|
||||||
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
|
|
||||||
radius="xl"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconDownload size={13} />}
|
|
||||||
mt="xs"
|
|
||||||
>
|
|
||||||
Download Certificate
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Box ta="center" py="xl">
|
|
||||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
|
||||||
<IconFileDescription size={22} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="sm" c="dimmed">No certificate on record</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Upload new certificate */}
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Group mb="md" gap="xs">
|
|
||||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
|
||||||
<IconUpload size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<TextInput
|
|
||||||
label="Issuing Doctor / Medical Centre"
|
|
||||||
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
|
|
||||||
value={doctorName}
|
|
||||||
onChange={(e) => setDoctorName(e.currentTarget.value)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
|
||||||
<TextInput
|
|
||||||
label="Issue Date"
|
|
||||||
type="date"
|
|
||||||
value={issuedDate}
|
|
||||||
onChange={(e) => setIssuedDate(e.currentTarget.value)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Expiry Date"
|
|
||||||
type="date"
|
|
||||||
value={expiryDate}
|
|
||||||
onChange={(e) => setExpiryDate(e.currentTarget.value)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
|
||||||
{uploadedFile ? (
|
|
||||||
<Card withBorder radius="sm" p="xs">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
|
||||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
|
|
||||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
|
|
||||||
<IconTrash size={13} />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
|
|
||||||
{(props) => (
|
|
||||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
|
||||||
Choose File (PDF / JPG / PNG, max 5MB)
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
|
||||||
<Text fz="xs">
|
|
||||||
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
|
|
||||||
Notifications will be sent by email and SMS.
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
leftSection={<IconCheck size={15} />}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={submitting}
|
|
||||||
disabled={!uploadedFile || !issuedDate || !expiryDate}
|
|
||||||
>
|
|
||||||
Submit for Verification
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* Notification schedule */}
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Group mb="md" gap="xs">
|
|
||||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
|
||||||
<IconCalendar size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700}>Expiry Notification Schedule</Text>
|
|
||||||
</Group>
|
|
||||||
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
|
|
||||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
|
|
||||||
<Text fz="xs" c="dimmed">First reminder — time to book your medical examination</Text>
|
|
||||||
</Timeline.Item>
|
|
||||||
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
|
|
||||||
<Text fz="xs" c="dimmed">Second reminder — urgent renewal required</Text>
|
|
||||||
</Timeline.Item>
|
|
||||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
|
|
||||||
<Text fz="xs" c="dimmed">Final reminder — certificate expires very soon</Text>
|
|
||||||
</Timeline.Item>
|
|
||||||
</Timeline>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* History */}
|
|
||||||
{history.length > 0 && (
|
|
||||||
<Paper withBorder radius="lg" p="lg">
|
|
||||||
<Text fw={700} mb="md">Certificate History</Text>
|
|
||||||
<Stack gap="xs">
|
|
||||||
{history.map((cert) => (
|
|
||||||
<Card key={cert.id} withBorder radius="sm" p="sm">
|
|
||||||
<Group justify="space-between" wrap="wrap" gap="xs">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="gray" size={32} radius="md">
|
|
||||||
<IconFileDescription size={16} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={600}>{cert.id}</Text>
|
|
||||||
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
|
|
||||||
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,8 @@ const ALWAYS_ALLOWED = [
|
|||||||
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
|
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
|
||||||
'/seafarer-registration',
|
'/seafarer-registration',
|
||||||
'/seafarer/records',
|
'/seafarer/records',
|
||||||
|
'/seafarer/sea-service',
|
||||||
|
'/seafarer/medical',
|
||||||
'/seafarer-registry',
|
'/seafarer-registry',
|
||||||
'/exams',
|
'/exams',
|
||||||
'/certificates',
|
'/certificates',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
|
||||||
|
|
||||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||||
SUBMITTED: 'blue',
|
SUBMITTED: 'blue',
|
||||||
@@ -37,6 +37,17 @@ export function seaServiceColumns(
|
|||||||
{t('seaRecords.columns.imo', { number: row.original.imoNumber })}
|
{t('seaRecords.columns.imo', { number: row.original.imoNumber })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{[
|
||||||
|
row.original.vesselType,
|
||||||
|
row.original.flagState,
|
||||||
|
row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -51,6 +62,15 @@ export function seaServiceColumns(
|
|||||||
accessorKey: 'dischargeDate',
|
accessorKey: 'dischargeDate',
|
||||||
cell: ({ row }) => showDate(row.original.dischargeDate),
|
cell: ({ row }) => showDate(row.original.dischargeDate),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t('seaRecords.columns.days', { defaultValue: 'Days' }),
|
||||||
|
align: 'right',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: t('common.status'),
|
header: t('common.status'),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
import { useDateDisplayer } from '@ema-platform/shared';
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
seaServiceDays,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
useCreateMedicalCertificateMutation,
|
useCreateMedicalCertificateMutation,
|
||||||
useCreateSeaServiceRecordMutation,
|
useCreateSeaServiceRecordMutation,
|
||||||
@@ -283,6 +283,15 @@ function SeaServiceTab() {
|
|||||||
form.dischargeDate &&
|
form.dischargeDate &&
|
||||||
form.engagementDate < form.dischargeDate;
|
form.engagementDate < form.dischargeDate;
|
||||||
|
|
||||||
|
// Shown under the date pickers as they are filled: the seafarer sees what
|
||||||
|
// the engagement is worth before saving it.
|
||||||
|
const formDays = seaServiceDays(form.engagementDate, form.dischargeDate);
|
||||||
|
// Every record as entered (verified or not), beside the approved figure.
|
||||||
|
const declaredDays = (records ?? []).reduce(
|
||||||
|
(sum, r) => sum + (seaServiceDays(r.engagementDate, r.dischargeDate) ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||||
const page = paginate(records ?? []);
|
const page = paginate(records ?? []);
|
||||||
|
|
||||||
@@ -303,6 +312,14 @@ function SeaServiceTab() {
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('seaRecords.seaService.description')}
|
{t('seaRecords.seaService.description')}
|
||||||
</Text>
|
</Text>
|
||||||
|
{(records ?? []).length > 0 && (
|
||||||
|
<Badge variant="light" color="blue">
|
||||||
|
{t('seaRecords.seaService.declaredSeaTime', {
|
||||||
|
days: declaredDays,
|
||||||
|
defaultValue: 'Declared sea time: {{days}} days',
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{seaTime && seaTime.verifiedRecords > 0 && (
|
{seaTime && seaTime.verifiedRecords > 0 && (
|
||||||
<Badge variant="light" color="teal">
|
<Badge variant="light" color="teal">
|
||||||
{t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })}
|
{t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })}
|
||||||
@@ -403,6 +420,23 @@ function SeaServiceTab() {
|
|||||||
dateFormat="date"
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{form.engagementDate && form.dischargeDate && (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color={formDays === null ? 'red' : 'teal'}
|
||||||
|
icon={<IconInfoCircle size={16} />}
|
||||||
|
py={6}
|
||||||
|
>
|
||||||
|
{formDays === null
|
||||||
|
? t('seaRecords.seaService.dateOrder', {
|
||||||
|
defaultValue: 'Discharge date must be after the engagement date.',
|
||||||
|
})
|
||||||
|
: t('seaRecords.seaService.daysServed', {
|
||||||
|
days: formDays,
|
||||||
|
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
|
||||||
|
})}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t('seaRecords.seaService.fields.duties')}
|
label={t('seaRecords.seaService.fields.duties')}
|
||||||
value={form.dutiesDescription}
|
value={form.dutiesDescription}
|
||||||
@@ -690,39 +724,36 @@ function MedicalTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sea-service history (US-SSM-001): every engagement, its days, its evidence. */
|
||||||
* The seafarer's evidence shelf (US-SSM-001/006): sea-service history and
|
export function SeaServicePage() {
|
||||||
* medical certificates, each with uploaded evidence, editable until an
|
|
||||||
* officer verifies them.
|
|
||||||
*/
|
|
||||||
export function MySeaRecordsPage() {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{t('seaRecords.title')}</Title>
|
<Group gap="xs">
|
||||||
<Alert
|
<IconAnchor size={22} />
|
||||||
variant="light"
|
<Title order={2}>{t('seaRecords.tabs.seaService')}</Title>
|
||||||
color="blue"
|
</Group>
|
||||||
icon={<IconInfoCircle size={16} />}
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
>
|
|
||||||
{t('seaRecords.pageIntro')}
|
{t('seaRecords.pageIntro')}
|
||||||
</Alert>
|
</Alert>
|
||||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
<SeaServiceTab />
|
||||||
<Tabs.List>
|
</Stack>
|
||||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
);
|
||||||
{t('seaRecords.tabs.seaService')}
|
}
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
/** Medical fitness certificates (US-SSM-006), editable until verified. */
|
||||||
{t('seaRecords.tabs.medical')}
|
export function MedicalRecordsPage() {
|
||||||
</Tabs.Tab>
|
const { t } = useTranslation();
|
||||||
</Tabs.List>
|
return (
|
||||||
<Tabs.Panel value="sea-service" pt="md">
|
<Stack>
|
||||||
<SeaServiceTab />
|
<Group gap="xs">
|
||||||
</Tabs.Panel>
|
<IconStethoscope size={22} />
|
||||||
<Tabs.Panel value="medical" pt="md">
|
<Title order={2}>{t('seaRecords.tabs.medical')}</Title>
|
||||||
<MedicalTab />
|
</Group>
|
||||||
</Tabs.Panel>
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
</Tabs>
|
{t('seaRecords.pageIntro')}
|
||||||
|
</Alert>
|
||||||
|
<MedicalTab />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export const am: Translations = {
|
|||||||
seafarerRegistration: 'የባህረኛ ምዝገባ',
|
seafarerRegistration: 'የባህረኛ ምዝገባ',
|
||||||
exams: 'ፈተናዎች',
|
exams: 'ፈተናዎች',
|
||||||
seaRecords: 'የባህር መዝገቦቼ',
|
seaRecords: 'የባህር መዝገቦቼ',
|
||||||
|
seaService: 'የባህር አገልግሎት',
|
||||||
|
medical: 'የሕክምና የምስክር ወረቀት',
|
||||||
myApplication: 'ማመልከቻዬ',
|
myApplication: 'ማመልከቻዬ',
|
||||||
certificates: 'የምስክር ወረቀቶች',
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const en = {
|
|||||||
seafarerRegistration: 'Seafarer Registration',
|
seafarerRegistration: 'Seafarer Registration',
|
||||||
exams: 'Examinations',
|
exams: 'Examinations',
|
||||||
seaRecords: 'My Sea Records',
|
seaRecords: 'My Sea Records',
|
||||||
|
seaService: 'Sea Service',
|
||||||
|
medical: 'Medical Certificate',
|
||||||
myApplication: 'My Application',
|
myApplication: 'My Application',
|
||||||
vesselRegistration: 'Vessel Registration',
|
vesselRegistration: 'Vessel Registration',
|
||||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppShell, Drawer } from "@mantine/core";
|
import { AppShell, Drawer } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
IconAnchor,
|
||||||
IconArrowsExchange,
|
IconArrowsExchange,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
IconShieldOff,
|
IconShieldOff,
|
||||||
IconShip,
|
IconShip,
|
||||||
|
IconStethoscope,
|
||||||
IconTruck,
|
IconTruck,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -99,11 +101,18 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
|||||||
permissions: [P.APPLY_SEAFARER_REGISTRATION],
|
permissions: [P.APPLY_SEAFARER_REGISTRATION],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/seafarer/records",
|
to: "/seafarer/sea-service",
|
||||||
label: "My Sea Records",
|
label: "Sea Service",
|
||||||
i18nKey: "nav.seaRecords",
|
i18nKey: "nav.seaService",
|
||||||
icon: IconList,
|
icon: IconAnchor,
|
||||||
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
|
permissions: [P.VIEW_OWN_SEA_SERVICE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/seafarer/medical",
|
||||||
|
label: "Medical Certificate",
|
||||||
|
i18nKey: "nav.medical",
|
||||||
|
icon: IconStethoscope,
|
||||||
|
permissions: [P.VIEW_OWN_MEDICAL],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// One page tracks both documents; the BTC needs no entry of its own.
|
// One page tracks both documents; the BTC needs no entry of its own.
|
||||||
@@ -190,7 +199,8 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
|||||||
"/licensing/applications": { i18nKey: "nav.myApplications" },
|
"/licensing/applications": { i18nKey: "nav.myApplications" },
|
||||||
"/waiver": { i18nKey: "nav.waiver" },
|
"/waiver": { i18nKey: "nav.waiver" },
|
||||||
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
|
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
|
||||||
"/seafarer/records": { i18nKey: "nav.seaRecords" },
|
"/seafarer/sea-service": { i18nKey: "nav.seaService" },
|
||||||
|
"/seafarer/medical": { i18nKey: "nav.medical" },
|
||||||
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
"/seaman-book": { i18nKey: "nav.seamanBook" },
|
||||||
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
||||||
"/certificates": { i18nKey: "nav.certificates" },
|
"/certificates": { i18nKey: "nav.certificates" },
|
||||||
|
|||||||
@@ -26,14 +26,13 @@ import { RequireOperations } from "./features/onboarding/components/RequireOpera
|
|||||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
|
||||||
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
|
||||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
|
|
||||||
// Phase 1 pages
|
// Phase 1 pages
|
||||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||||
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
||||||
import { MedicalCertificatePage } from "./features/medical/pages/MedicalCertificatePage";
|
|
||||||
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
|
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
|
||||||
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||||
|
|
||||||
@@ -188,16 +187,24 @@ export const router = createBrowserRouter([
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
// Sea service and medical certificates each have a page of their own.
|
||||||
{
|
{
|
||||||
path: "/seafarer/records",
|
path: "/seafarer/sea-service",
|
||||||
element: (
|
element: (
|
||||||
<RequirePermission
|
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE]}>
|
||||||
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
|
<SeaServicePage />
|
||||||
>
|
|
||||||
<MySeaRecordsPage />
|
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/seafarer/medical",
|
||||||
|
element: (
|
||||||
|
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
|
||||||
|
<MedicalRecordsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
|
||||||
{
|
{
|
||||||
path: "/exams",
|
path: "/exams",
|
||||||
element: (
|
element: (
|
||||||
@@ -239,14 +246,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
// Requested automatically with the seafarer registration — nothing to file.
|
// Requested automatically with the seafarer registration — nothing to file.
|
||||||
{ path: "/seaman-book/apply", element: <Navigate to="/seaman-book" replace /> },
|
{ path: "/seaman-book/apply", element: <Navigate to="/seaman-book" replace /> },
|
||||||
{
|
{ path: "/medical", element: <Navigate to="/seafarer/medical" replace /> },
|
||||||
path: "/medical",
|
|
||||||
element: (
|
|
||||||
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
|
|
||||||
<MedicalCertificatePage />
|
|
||||||
</RequirePermission>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/basic-safety-training",
|
path: "/basic-safety-training",
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './seafarer.types';
|
export * from './seafarer.types';
|
||||||
export * from './seafarer-api';
|
export * from './seafarer-api';
|
||||||
|
export * from './seafarer.helpers';
|
||||||
|
|||||||
23
libs/api/src/lib/features/seafarer/seafarer.helpers.ts
Normal file
23
libs/api/src/lib/features/seafarer/seafarer.helpers.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Days served on one engagement, counted inclusively — embarkation and
|
||||||
|
* discharge days both count. The same arithmetic the API uses for approved
|
||||||
|
* sea time (`SeafarerRecordService.approvedSeaTime`), so the figure a seafarer
|
||||||
|
* sees while typing is the figure the eligibility gate will credit.
|
||||||
|
*
|
||||||
|
* `null` until both dates are set or while they are out of order.
|
||||||
|
*/
|
||||||
|
export function seaServiceDays(
|
||||||
|
engagementDate: string | null | undefined,
|
||||||
|
dischargeDate: string | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!engagementDate || !dischargeDate) return null;
|
||||||
|
const from = Date.UTC(...dateParts(engagementDate));
|
||||||
|
const to = Date.UTC(...dateParts(dischargeDate));
|
||||||
|
if (Number.isNaN(from) || Number.isNaN(to) || to < from) return null;
|
||||||
|
return Math.round((to - from) / 86_400_000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParts(value: string): [number, number, number] {
|
||||||
|
const [y, m, d] = value.slice(0, 10).split('-').map(Number);
|
||||||
|
return [y, (m || 1) - 1, d || 1];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user