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:
Nati
2026-08-20 11:01:04 +00:00
parent db5b7bdf70
commit 558cfe20a1
16 changed files with 239 additions and 471 deletions

View File

@@ -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),
]; ];
} }

View File

@@ -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;

View File

@@ -92,7 +92,8 @@ export const am: Translations = {
applications: "ማመልከቻዎች", applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት", paymentConfig: "የክፍያ ውቅረት",
analytics: "ትንታኔ", analytics: "ትንታኔ",
medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ", seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
medicalVerification: "የሕክምና ማረጋገጫ",
locations: "አካባቢዎች", locations: "አካባቢዎች",
configuration: "ውቅረት", configuration: "ውቅረት",
profile: "መገለጫ", profile: "መገለጫ",

View File

@@ -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',

View File

@@ -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] },
], ],
}, },

View File

@@ -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.

View File

@@ -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>
);
}

View File

@@ -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',

View File

@@ -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 }) => (

View File

@@ -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>
); );
} }

View File

@@ -53,6 +53,8 @@ export const am: Translations = {
seafarerRegistration: 'የባህረኛ ምዝገባ', seafarerRegistration: 'የባህረኛ ምዝገባ',
exams: 'ፈተናዎች', exams: 'ፈተናዎች',
seaRecords: 'የባህር መዝገቦቼ', seaRecords: 'የባህር መዝገቦቼ',
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀት',
myApplication: 'ማመልከቻዬ', myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች', certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC', seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',

View File

@@ -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',

View File

@@ -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" },

View File

@@ -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: (

View File

@@ -1,2 +1,3 @@
export * from './seafarer.types'; export * from './seafarer.types';
export * from './seafarer-api'; export * from './seafarer-api';
export * from './seafarer.helpers';

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