diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx index d76514579..ce6450686 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/columns.tsx @@ -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} )} + {(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && ( + + {[ + row.original.vesselType, + row.original.flagState, + row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null, + ] + .filter(Boolean) + .join(' · ')} + + )} ), }, @@ -151,6 +163,16 @@ export function seaServiceColumns( ), }, + { + header: t('recordVerification.columns.days', 'Days'), + label: t('recordVerification.columns.days', 'Days'), + align: 'right', + cell: ({ row }) => ( + + {seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'} + + ), + }, statusColumn(t), ]; } diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx index d4bc9e434..9d79075a7 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage/index.tsx @@ -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('SUBMITTED'); const { @@ -372,74 +372,62 @@ export function MedicalVerificationPage() { [rulingSeaService, rule, verifySeaService, showDate, t], ); + const isMedical = kind === 'medical'; + return ( - {t('recordVerification.title', 'Record verification')} + {isMedical + ? t('recordVerification.medicalTitle', 'Medical Certificate Verification') + : t('recordVerification.seaServiceTitle', 'Sea Service Verification')} - {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.', + )} - - - }> - {t('recordVerification.tabs.medical', { - count: pendingMedicalList.length, - defaultValue: 'Medical ({{count}})', - })} - - }> - {t('recordVerification.tabs.seaService', { - count: pendingSeaServiceList.length, - defaultValue: 'Sea Service ({{count}})', - })} - - - - - {statusFilter} - - - - - {statusFilter} - - - + {isMedical ? ( + + ) : ( + + )} ; + export default MedicalVerificationPage; diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 369da1a24..043bc95a2 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -92,7 +92,8 @@ export const am: Translations = { applications: "ማመልከቻዎች", paymentConfig: "የክፍያ ውቅረት", analytics: "ትንታኔ", - medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ", + seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ", + medicalVerification: "የሕክምና ማረጋገጫ", locations: "አካባቢዎች", configuration: "ውቅረት", profile: "መገለጫ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 82a7e3d28..fa113749a 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index cf6b72181..e7ac31ff9 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -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] }, ], }, diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 14a60caca..3635f5745 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -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: }, { path: 'endorsement-queue/:id', element: }, { path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, + { path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], ) }, { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], ) }, // Seafarer registration is not a licence: own queue, own review. diff --git a/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx b/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx deleted file mode 100644 index 121f3fbea..000000000 --- a/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx +++ /dev/null @@ -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 = { - 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({ - url: '/medical/my', - method: 'GET', - }); - const current = medical?.current ?? null; - const history = medical?.history ?? []; - const [uploadedFile, setUploadedFile] = useState(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 ( - - {/* Header */} -
- Medical Certificate - - STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18). - -
- - {/* Validity alert */} - {current && days <= 90 && days > 0 && ( - }> - Your medical certificate expires in {days} days ({formatDate(current.expiryDate)}). - Please visit an EMA-approved medical centre and upload your renewed certificate below. - - )} - {current && days <= 0 && ( - }> - Your medical certificate has expired. You cannot join a vessel until a valid certificate is uploaded and verified. - - )} - {!current && ( - }> - No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book. - - )} - - - {/* Current certificate */} - - - - - - Current Certificate - - - {current ? ( - - - Status - {current.status} - - - Certificate ID - {current.id} - - - Issued By - {current.issuedBy} - - - Issue Date - {formatDate(current.issuedDate)} - - - Expiry Date - - {formatDate(current.expiryDate)} - - - - Restrictions - {current.restrictions} - - - {/* Validity bar */} - - - Validity remaining - {Math.max(0, days)} days - - - - - - - ) : ( - - - - - No certificate on record - - )} - - - {/* Upload new certificate */} - - - - - - {current ? 'Upload Renewal' : 'Upload Certificate'} - - - - setDoctorName(e.currentTarget.value)} - size="sm" - /> - - setIssuedDate(e.currentTarget.value)} - size="sm" - /> - setExpiryDate(e.currentTarget.value)} - size="sm" - /> - - -
- Certificate File * - {uploadedFile ? ( - - - - {uploadedFile.name} - - - - ) : ( - - {(props) => ( - - )} - - )} -
- - } p="xs"> - - Your certificate will be reviewed by an EMA Medical Officer within 2 working days. - Notifications will be sent by email and SMS. - - - - -
-
-
- - {/* Notification schedule */} - - - - - - Expiry Notification Schedule - - - } title="90 Days Before Expiry"> - First reminder — time to book your medical examination - - } title="60 Days Before Expiry"> - Second reminder — urgent renewal required - - } title="30 Days Before Expiry"> - Final reminder — certificate expires very soon - - - - - {/* History */} - {history.length > 0 && ( - - Certificate History - - {history.map((cert) => ( - - - - - - -
- {cert.id} - {formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)} -
-
- - {cert.status} - - -
-
- ))} -
-
- )} -
- ); -} diff --git a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx index b565eb96c..c50ef617d 100644 --- a/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx +++ b/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx @@ -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', diff --git a/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx b/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx index 7e5196d85..b9956e79d 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeaRecords/columns.tsx @@ -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 = { SUBMITTED: 'blue', @@ -37,6 +37,17 @@ export function seaServiceColumns( {t('seaRecords.columns.imo', { number: row.original.imoNumber })} )} + {(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && ( + + {[ + row.original.vesselType, + row.original.flagState, + row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null, + ] + .filter(Boolean) + .join(' · ')} + + )} ), }, @@ -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 }) => ( + + {seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'} + + ), + }, { header: t('common.status'), cell: ({ row }) => ( diff --git a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx index 25dc0fd11..01e419ddc 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeaRecords/index.tsx @@ -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() { {t('seaRecords.seaService.description')} + {(records ?? []).length > 0 && ( + + {t('seaRecords.seaService.declaredSeaTime', { + days: declaredDays, + defaultValue: 'Declared sea time: {{days}} days', + })} + + )} {seaTime && seaTime.verifiedRecords > 0 && ( {t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })} @@ -403,6 +420,23 @@ function SeaServiceTab() { dateFormat="date" /> + {form.engagementDate && form.dischargeDate && ( + } + 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)', + })} + + )}