From 35c798704756b01f2a8f1f1c51e6c5d9de021ead Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Mon, 17 Aug 2026 01:18:59 +0300 Subject: [PATCH] feat(portal): wire the medical, BST and seaman-book screens to the API These three carried hardcoded sample data -- a certificate expiring in 2026, a fully-ticked training list, an eligibility checklist that always passed. They now read the portal endpoints and show the seafarer's own record. Expiry is taken from the server rather than recomputed in the browser: it is the same figure the eligibility gate uses, and a client clock that is wrong or in another timezone would otherwise show a seafarer a different number than the officer sees. Eligibility likewise -- the screen asks whether it may apply rather than deciding for itself, so it cannot offer a button the API then refuses. The seaman-book stepper is derived from the application's status instead of a stored timeline, because the status is what the workflow actually moves; a second record of the same journey would only drift out of step. Status colours are keyed by the workflow's own values, so an unmapped status falls back to grey rather than vanishing. /medical and /basic-safety-training had no route at all -- both silently fell through to the dashboard, which is why the pages looked unreachable rather than merely unwired. Co-Authored-By: Claude Opus 5 --- .../pages/BasicSafetyTrainingPage.tsx | 64 ++++- .../medical/pages/MedicalCertificatePage.tsx | 54 +++-- .../seaman-book/pages/SeamanBookPage.tsx | 224 +++++++++++------- apps/portal/src/app/router.tsx | 18 ++ 4 files changed, 245 insertions(+), 115 deletions(-) diff --git a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx index caba3b94d..1b0d7ff07 100644 --- a/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx +++ b/apps/portal/src/app/features/basic-safety-training/pages/BasicSafetyTrainingPage.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { useApiQuery } from '@ema-platform/api'; import { Alert, Badge, @@ -50,12 +51,22 @@ const STATUS_COLOR: Record = { 'Pending Verification': 'yellow', }; +/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */ +interface BstProgress { + modules: { key: string; label: string; licenseTypeKey: string; done: boolean }[]; + completed: number; + total: number; + complete: boolean; +} + +// `short` doubles as the key the API reports each module under, so the two +// stay matched without a second lookup table between them. const BST_COMPONENTS = [ { label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' }, { label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' }, { label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' }, { label: 'Personal Safety & Social Responsibility', short: 'PSSR', course: 'IMO 1.21' }, - { label: 'Sexual Harassment Prevention', short: 'SHPT', course: 'EMA National' }, + { label: 'Security Awareness', short: 'SSA', course: 'STCW A-VI/6' }, ]; function formatDate(dateStr: string) { @@ -249,6 +260,17 @@ export function BasicSafetyTrainingPage() { const [record, setRecord] = useState(null); const [modalOpen, setModalOpen] = useState(false); + // Which of the five modules the seafarer actually holds. The certificate + // itself is the evidence, so this is read from the issued licences rather + // than tracked separately — two places to record it would disagree. + const { data: bst, isLoading: bstLoading } = useApiQuery({ + url: '/bst/my', + method: 'GET', + }); + const doneByKey = new Map( + (bst?.modules ?? []).map((m) => [m.key, m.done]), + ); + const days = record?.expiryDate ? daysUntil(record.expiryDate) : null; const isExpiringSoon = days !== null && days <= 180 && days > 0; const isExpired = days !== null && days <= 0; @@ -398,15 +420,37 @@ export function BasicSafetyTrainingPage() { } > - {BST_COMPONENTS.map((c) => ( - - - {c.short} - — {c.label} - {c.course} - - - ))} + {BST_COMPONENTS.map((c) => { + const done = doneByKey.get(c.short); + return ( + + + + } + > + + {c.short} + — {c.label} + {c.course} + {/* Only stated once known: an absent badge reads as "not + loaded", where a "Not held" badge would read as fact. */} + {!bstLoading && ( + + {done ? 'Held' : 'Not held'} + + )} + + + ); + })} diff --git a/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx b/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx index dbf0c9f31..40009b065 100644 --- a/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx +++ b/apps/portal/src/app/features/medical/pages/MedicalCertificatePage.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { useApiQuery } from '@ema-platform/api'; import { Alert, Badge, @@ -16,7 +17,6 @@ import { ThemeIcon, Timeline, Title, - rem, } from '@mantine/core'; import { IconAlertCircle, @@ -33,32 +33,22 @@ import { } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; -// --------------------------------------------------------------------------- -// Mock current certificate — replace with real API data -// --------------------------------------------------------------------------- -const MOCK_CURRENT: MedicalCert | null = { - id: 'MC-2024-001', - issuedBy: 'EMA Approved Medical Center — Addis Ababa', - issuedDate: '2024-03-15', - expiryDate: '2026-03-14', - status: 'Expiring', - restrictions: 'None', - fileName: 'medical_cert_2024.pdf', -}; - -const MOCK_HISTORY: MedicalCert[] = [ - { id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' }, - { id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' }, -]; - interface MedicalCert { id: string; issuedBy: string; issuedDate: string; expiryDate: string; - status: 'Valid' | 'Expiring' | 'Expired' | 'Pending'; + status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected'; restrictions: string; - fileName: 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 = { @@ -77,7 +67,16 @@ function formatDate(dateStr: string): string { // Component // --------------------------------------------------------------------------- export function MedicalCertificatePage() { - const [current] = useState(MOCK_CURRENT); + // 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(''); @@ -85,7 +84,12 @@ export function MedicalCertificatePage() { const [submitting, setSubmitting] = useState(false); const resetRef = useRef<() => void>(null); - const days = current ? daysUntil(current.expiryDate) : 0; + // 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; @@ -304,11 +308,11 @@ export function MedicalCertificatePage() { {/* History */} - {MOCK_HISTORY.length > 0 && ( + {history.length > 0 && ( Certificate History - {MOCK_HISTORY.map((cert) => ( + {history.map((cert) => ( diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx index 138192e30..0f260539c 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useApiQuery } from '@ema-platform/api'; import { Alert, Badge, @@ -14,9 +14,7 @@ import { Stepper, Text, ThemeIcon, - Timeline, Title, - rem, } from '@mantine/core'; import { IconAlertCircle, @@ -31,44 +29,88 @@ import { IconShield, IconX, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; -// --------------------------------------------------------------------------- -// Mock data — replace with real API -// --------------------------------------------------------------------------- -const ELIGIBILITY = { - hasProfile: true, - hasNationalId: true, - hasMedicalCert: true, - medicalExpiry: '2026-03-14', - bstComplete: true, - bstItems: [ - { label: 'Personal Survival Techniques (PST)', done: true }, - { label: 'Fire Prevention & Fire Fighting (FPFF)', done: true }, - { label: 'Elementary First Aid (EFA)', done: true }, - { label: 'Personal Safety & Social Responsibility (PSSR)', done: true }, - { label: 'Sexual Harassment Prevention', done: true }, - ], -}; - -const MOCK_APPLICATION: SeamanBookApp | null = null; - -interface SeamanBookApp { - id: string; - submittedAt: string; - status: string; - remarks: string; - timeline: { date: string | null; event: string; done: boolean }[]; +/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */ +interface SeamanBookOverview { + application: { + id: string; + applicationId: string; + status: string; + submittedAt: string; + } | null; + book: { + id: string; + issuedDate: string; + expiryDate: string; + status: string; + } | null; + eligibility: { + hasProfile: boolean; + hasSeafarerNumber: boolean; + hasMedical: boolean; + medicalExpiry: string | null; + bstComplete: boolean; + bstModules: { key: string; label: string; done: boolean }[]; + }; + eligible: boolean; } +/** + * The stages an application passes through, for the progress stepper. + * + * Derived from the application's status rather than stored as a timeline: + * the status is what the workflow actually moves, so a second record of the + * same journey would only drift out of step with it. + */ +const STAGES: { label: string; statuses: string[] }[] = [ + { label: 'Submitted', statuses: ['SUBMITTED', 'UNDER_REVIEW', 'UNDER_EVALUATION'] }, + { label: 'Under Review', statuses: ['UNDER_REVIEW', 'UNDER_EVALUATION'] }, + { label: 'Inspection', statuses: ['INSPECTION_PENDING', 'INSPECTION_COMPLETED'] }, + { label: 'Approved', statuses: ['APPROVED', 'PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED'] }, + { label: 'Issued', statuses: ['CERTIFICATE_ISSUED', 'COMPLETED'] }, +]; + +/** How far along the stepper a status sits; -1 for a draft. */ +function stageIndexFor(status: string | undefined): number { + if (!status || status === 'DRAFT') return -1; + let reached = -1; + STAGES.forEach((stage, i) => { + if (stage.statuses.includes(status)) reached = i; + }); + // A status past the last named stage (e.g. REJECTED) still shows the + // journey taken rather than collapsing the stepper to nothing. + return reached; +} + +// Keyed by the workflow's own status values, not display strings: the badge +// reads whatever the API reports, and an unmapped status falls back to grey +// rather than vanishing. const STATUS_COLOR: Record = { - 'Under Review': 'yellow', - 'Approved': 'teal', - 'Rejected': 'red', - 'Correction Required': 'orange', - 'Ready for Collection': 'blue', + DRAFT: 'gray', + SUBMITTED: 'blue', + UNDER_REVIEW: 'yellow', + UNDER_EVALUATION: 'yellow', + RESUBMIT_REQUIRED: 'orange', + INSPECTION_PENDING: 'grape', + INSPECTION_COMPLETED: 'grape', + APPROVED: 'teal', + REJECTED: 'red', + ON_HOLD: 'orange', + PAYMENT_PENDING: 'orange', + PAID: 'blue', + PAYMENT_CONFIRMED: 'blue', + CERTIFICATE_ISSUED: 'teal', + COMPLETED: 'teal', }; +function formatDate(value: string): string { + return new Date(value).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + function EligibilityItem({ label, ok }: { label: string; ok: boolean }) { return ( @@ -85,27 +127,23 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) { // --------------------------------------------------------------------------- export function SeamanBookPage() { const navigate = useNavigate(); - const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION); - const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length; - const isEligible = - ELIGIBILITY.hasProfile && - ELIGIBILITY.hasNationalId && - ELIGIBILITY.hasMedicalCert && - ELIGIBILITY.bstComplete; + const { data, isLoading } = useApiQuery({ + url: '/seaman-book/my', + method: 'GET', + }); - const handleApply = async () => { - setSubmitting(true); - await new Promise((r) => setTimeout(r, 1400)); - setSubmitting(false); - setSubmitted(true); - notify.success('Seaman Book application submitted successfully! Reference: SB-APP-2024-002'); - }; + const application = data?.application ?? null; + const eligibility = data?.eligibility; + const bstItems = eligibility?.bstModules ?? []; + const bstDone = bstItems.filter((b) => b.done).length; - const activeStep = MOCK_APPLICATION - ? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1 - : -1; + // The server decides: the same checklist gates the submission, so a screen + // that judged eligibility for itself could offer a button the API refuses. + const isEligible = data?.eligible ?? false; + const submitted = Boolean(application); + + const activeStep = stageIndexFor(application?.status); return ( @@ -119,7 +157,7 @@ export function SeamanBookPage() { {/* Active application status */} - {MOCK_APPLICATION && ( + {application && ( @@ -127,36 +165,43 @@ export function SeamanBookPage() {
- Application {MOCK_APPLICATION.id} - Submitted {MOCK_APPLICATION.submittedAt} + Application {application.id} + + Submitted {formatDate(application.submittedAt)} +
- - {MOCK_APPLICATION.status} + + {application.status.replaceAll('_', ' ')}
- {MOCK_APPLICATION.remarks && ( - } mb="md" p="sm"> - {MOCK_APPLICATION.remarks} - - )} - {/* Progress stepper */} - {MOCK_APPLICATION.timeline.map((step, i) => ( + {STAGES.map((stage, i) => ( : } + key={stage.label} + label={stage.label} + description={i <= activeStep ? 'Done' : 'Pending'} + icon={ + i <= activeStep ? ( + + ) : ( + + ) + } /> ))} - {MOCK_APPLICATION.status === 'Ready for Collection' && ( + {data?.book && ( } mt="md"> - Your Seaman Book is ready. Please visit the EMA office to collect it. Bring your National ID. + Your Seaman Book {data.book.id} has been issued. + Please visit the EMA office to collect it, bringing your National ID. )}
@@ -175,19 +220,39 @@ export function SeamanBookPage() {
- - - + + + - - {ELIGIBILITY.bstItems.map((item) => ( - + + {bstItems.map((item) => ( + ))} - {!isEligible && ( + {!isLoading && !isEligible && ( } mt="xs" p="sm"> - Complete all requirements above before applying. Missing BST: {5 - bstDone} certificate(s). + Complete all requirements above before applying. + {bstItems.length > bstDone + ? ` Missing BST: ${bstItems.length - bstDone} certificate(s).` + : ''} )} @@ -266,7 +331,6 @@ export function SeamanBookPage() {