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() {