mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02: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 type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
SeafarerRecordStatus,
|
||||
import {
|
||||
seaServiceDays,
|
||||
type MedicalCertificate,
|
||||
type SeaServiceRecord,
|
||||
type SeafarerProfileSummary,
|
||||
type SeafarerRecordStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
@@ -133,6 +134,17 @@ export function seaServiceColumns(
|
||||
IMO {row.original.imoNumber}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
@@ -151,6 +163,16 @@ export function seaServiceColumns(
|
||||
</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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,18 +11,11 @@ import {
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconEye,
|
||||
IconInbox,
|
||||
IconPaperclip,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
|
||||
import {
|
||||
AdvancedTable,
|
||||
notify,
|
||||
@@ -196,7 +189,14 @@ function AttachmentsModal({
|
||||
* freezes a record — sea service starts counting toward sea time, a medical
|
||||
* 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 [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
|
||||
const {
|
||||
@@ -372,74 +372,62 @@ export function MedicalVerificationPage() {
|
||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||
);
|
||||
|
||||
const isMedical = kind === 'medical';
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
{t('recordVerification.title', 'Record verification')}
|
||||
{isMedical
|
||||
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
|
||||
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t(
|
||||
'recordVerification.subtitle',
|
||||
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
{isMedical
|
||||
? t(
|
||||
'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>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
{t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
{t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
{statusFilter}
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</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>
|
||||
{isMedical ? (
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
) : (
|
||||
<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={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AttachmentsModal
|
||||
opened={Boolean(attachmentModal)}
|
||||
@@ -491,4 +479,6 @@ export function MedicalVerificationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
|
||||
@@ -92,7 +92,8 @@ export const am: Translations = {
|
||||
applications: "ማመልከቻዎች",
|
||||
paymentConfig: "የክፍያ ውቅረት",
|
||||
analytics: "ትንታኔ",
|
||||
medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ",
|
||||
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
|
||||
medicalVerification: "የሕክምና ማረጋገጫ",
|
||||
locations: "አካባቢዎች",
|
||||
configuration: "ውቅረት",
|
||||
profile: "መገለጫ",
|
||||
|
||||
@@ -90,7 +90,8 @@ export const en = {
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
analytics: 'Analytics',
|
||||
medicalVerification: 'Medical and Sea Service Verification',
|
||||
seaServiceVerification: 'Sea Service Verification',
|
||||
medicalVerification: 'Medical Verification',
|
||||
locations: 'Locations',
|
||||
configuration: 'Configuration',
|
||||
profile: 'Profile',
|
||||
|
||||
@@ -102,6 +102,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
{ 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_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] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -22,7 +22,10 @@ import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
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 { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
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/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ 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: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
// 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).
|
||||
'/seafarer-registration',
|
||||
'/seafarer/records',
|
||||
'/seafarer/sea-service',
|
||||
'/seafarer/medical',
|
||||
'/seafarer-registry',
|
||||
'/exams',
|
||||
'/certificates',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
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> = {
|
||||
SUBMITTED: 'blue',
|
||||
@@ -37,6 +37,17 @@ export function seaServiceColumns(
|
||||
{t('seaRecords.columns.imo', { number: row.original.imoNumber })}
|
||||
</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',
|
||||
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'),
|
||||
cell: ({ row }) => (
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
@@ -38,6 +37,7 @@ import {
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
seaServiceDays,
|
||||
uploadDocument,
|
||||
useCreateMedicalCertificateMutation,
|
||||
useCreateSeaServiceRecordMutation,
|
||||
@@ -283,6 +283,15 @@ function SeaServiceTab() {
|
||||
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 page = paginate(records ?? []);
|
||||
|
||||
@@ -303,6 +312,14 @@ function SeaServiceTab() {
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seaRecords.seaService.description')}
|
||||
</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 && (
|
||||
<Badge variant="light" color="teal">
|
||||
{t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })}
|
||||
@@ -403,6 +420,23 @@ function SeaServiceTab() {
|
||||
dateFormat="date"
|
||||
/>
|
||||
</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
|
||||
label={t('seaRecords.seaService.fields.duties')}
|
||||
value={form.dutiesDescription}
|
||||
@@ -690,39 +724,36 @@ function MedicalTab() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
/** Sea-service history (US-SSM-001): every engagement, its days, its evidence. */
|
||||
export function SeaServicePage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t('seaRecords.title')}</Title>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconAnchor size={22} />
|
||||
<Title order={2}>{t('seaRecords.tabs.seaService')}</Title>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
{t('seaRecords.pageIntro')}
|
||||
</Alert>
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
{t('seaRecords.tabs.seaService')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
{t('seaRecords.tabs.medical')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<SeaServiceTab />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<MedicalTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<SeaServiceTab />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Medical fitness certificates (US-SSM-006), editable until verified. */
|
||||
export function MedicalRecordsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="xs">
|
||||
<IconStethoscope size={22} />
|
||||
<Title order={2}>{t('seaRecords.tabs.medical')}</Title>
|
||||
</Group>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
{t('seaRecords.pageIntro')}
|
||||
</Alert>
|
||||
<MedicalTab />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export const am: Translations = {
|
||||
seafarerRegistration: 'የባህረኛ ምዝገባ',
|
||||
exams: 'ፈተናዎች',
|
||||
seaRecords: 'የባህር መዝገቦቼ',
|
||||
seaService: 'የባህር አገልግሎት',
|
||||
medical: 'የሕክምና የምስክር ወረቀት',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
|
||||
@@ -42,6 +42,8 @@ export const en = {
|
||||
seafarerRegistration: 'Seafarer Registration',
|
||||
exams: 'Examinations',
|
||||
seaRecords: 'My Sea Records',
|
||||
seaService: 'Sea Service',
|
||||
medical: 'Medical Certificate',
|
||||
myApplication: 'My Application',
|
||||
vesselRegistration: 'Vessel Registration',
|
||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppShell, Drawer } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconBell,
|
||||
IconBook2,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconStethoscope,
|
||||
IconTruck,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -99,11 +101,18 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
permissions: [P.APPLY_SEAFARER_REGISTRATION],
|
||||
},
|
||||
{
|
||||
to: "/seafarer/records",
|
||||
label: "My Sea Records",
|
||||
i18nKey: "nav.seaRecords",
|
||||
icon: IconList,
|
||||
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
|
||||
to: "/seafarer/sea-service",
|
||||
label: "Sea Service",
|
||||
i18nKey: "nav.seaService",
|
||||
icon: IconAnchor,
|
||||
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.
|
||||
@@ -190,7 +199,8 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
"/licensing/applications": { i18nKey: "nav.myApplications" },
|
||||
"/waiver": { i18nKey: "nav.waiver" },
|
||||
"/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" },
|
||||
"/basic-training-certificate": { i18nKey: "nav.btc" },
|
||||
"/certificates": { i18nKey: "nav.certificates" },
|
||||
|
||||
@@ -26,14 +26,13 @@ import { RequireOperations } from "./features/onboarding/components/RequireOpera
|
||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
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 { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
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 { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||
|
||||
@@ -188,16 +187,24 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
// Sea service and medical certificates each have a page of their own.
|
||||
{
|
||||
path: "/seafarer/records",
|
||||
path: "/seafarer/sea-service",
|
||||
element: (
|
||||
<RequirePermission
|
||||
anyOf={[P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL]}
|
||||
>
|
||||
<MySeaRecordsPage />
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_SEA_SERVICE]}>
|
||||
<SeaServicePage />
|
||||
</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",
|
||||
element: (
|
||||
@@ -239,14 +246,7 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
// Requested automatically with the seafarer registration — nothing to file.
|
||||
{ path: "/seaman-book/apply", element: <Navigate to="/seaman-book" replace /> },
|
||||
{
|
||||
path: "/medical",
|
||||
element: (
|
||||
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
|
||||
<MedicalCertificatePage />
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{ path: "/medical", element: <Navigate to="/seafarer/medical" replace /> },
|
||||
{
|
||||
path: "/basic-safety-training",
|
||||
element: (
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './seafarer.types';
|
||||
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