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 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-17 01:18:59 +03:00
parent 702496202f
commit 35c7987047
4 changed files with 245 additions and 115 deletions

View File

@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { import {
Alert, Alert,
Badge, Badge,
@@ -50,12 +51,22 @@ const STATUS_COLOR: Record<string, string> = {
'Pending Verification': 'yellow', '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 = [ const BST_COMPONENTS = [
{ label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' }, { label: 'Personal Survival Techniques', short: 'PST', course: 'IMO 1.19' },
{ label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' }, { label: 'Fire Prevention & Fire Fighting', short: 'FPFF', course: 'IMO 1.20' },
{ label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' }, { label: 'Elementary First Aid', short: 'EFA', course: 'IMO 1.13' },
{ label: 'Personal Safety & Social Responsibility', short: 'PSSR', course: 'IMO 1.21' }, { 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) { function formatDate(dateStr: string) {
@@ -249,6 +260,17 @@ export function BasicSafetyTrainingPage() {
const [record, setRecord] = useState<BSTRecord | null>(null); const [record, setRecord] = useState<BSTRecord | null>(null);
const [modalOpen, setModalOpen] = useState(false); 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<BstProgress>({
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 days = record?.expiryDate ? daysUntil(record.expiryDate) : null;
const isExpiringSoon = days !== null && days <= 180 && days > 0; const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0; const isExpired = days !== null && days <= 0;
@@ -398,15 +420,37 @@ export function BasicSafetyTrainingPage() {
</ThemeIcon> </ThemeIcon>
} }
> >
{BST_COMPONENTS.map((c) => ( {BST_COMPONENTS.map((c) => {
<List.Item key={c.short}> const done = doneByKey.get(c.short);
<Group gap="xs" display="inline-flex"> return (
<Text fz="sm" fw={600}>{c.short}</Text> <List.Item
<Text fz="sm" c="dimmed"> {c.label}</Text> key={c.short}
<Badge size="xs" variant="outline" color="gray">{c.course}</Badge> icon={
</Group> <ThemeIcon
</List.Item> size={18}
))} radius="xl"
color={done ? 'teal' : 'gray'}
variant={done ? 'light' : 'outline'}
>
<IconCheck size={11} />
</ThemeIcon>
}
>
<Group gap="xs" display="inline-flex">
<Text fz="sm" fw={600}>{c.short}</Text>
<Text fz="sm" c="dimmed"> {c.label}</Text>
<Badge size="xs" variant="outline" color="gray">{c.course}</Badge>
{/* Only stated once known: an absent badge reads as "not
loaded", where a "Not held" badge would read as fact. */}
{!bstLoading && (
<Badge size="xs" variant="light" color={done ? 'teal' : 'gray'}>
{done ? 'Held' : 'Not held'}
</Badge>
)}
</Group>
</List.Item>
);
})}
</List> </List>
</Paper> </Paper>

View File

@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { import {
Alert, Alert,
Badge, Badge,
@@ -16,7 +17,6 @@ import {
ThemeIcon, ThemeIcon,
Timeline, Timeline,
Title, Title,
rem,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAlertCircle, IconAlertCircle,
@@ -33,32 +33,22 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; 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 { interface MedicalCert {
id: string; id: string;
issuedBy: string; issuedBy: string;
issuedDate: string; issuedDate: string;
expiryDate: string; expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending'; status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
restrictions: string; 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<string, string> = { const STATUS_COLOR: Record<string, string> = {
@@ -77,7 +67,16 @@ function formatDate(dateStr: string): string {
// Component // Component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function MedicalCertificatePage() { export function MedicalCertificatePage() {
const [current] = useState<MedicalCert | null>(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<MedicalOverview>({
url: '/medical/my',
method: 'GET',
});
const current = medical?.current ?? null;
const history = medical?.history ?? [];
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [doctorName, setDoctorName] = useState(''); const [doctorName, setDoctorName] = useState('');
const [issuedDate, setIssuedDate] = useState(''); const [issuedDate, setIssuedDate] = useState('');
@@ -85,7 +84,12 @@ export function MedicalCertificatePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const resetRef = useRef<() => void>(null); 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 const progressVal = current
? Math.max(0, Math.min(100, (days / 730) * 100)) ? Math.max(0, Math.min(100, (days / 730) * 100))
: 0; : 0;
@@ -304,11 +308,11 @@ export function MedicalCertificatePage() {
</Paper> </Paper>
{/* History */} {/* History */}
{MOCK_HISTORY.length > 0 && ( {history.length > 0 && (
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text> <Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs"> <Stack gap="xs">
{MOCK_HISTORY.map((cert) => ( {history.map((cert) => (
<Card key={cert.id} withBorder radius="sm" p="sm"> <Card key={cert.id} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="wrap" gap="xs"> <Group justify="space-between" wrap="wrap" gap="xs">
<Group gap="sm"> <Group gap="sm">

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useApiQuery } from '@ema-platform/api';
import { import {
Alert, Alert,
Badge, Badge,
@@ -14,9 +14,7 @@ import {
Stepper, Stepper,
Text, Text,
ThemeIcon, ThemeIcon,
Timeline,
Title, Title,
rem,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAlertCircle, IconAlertCircle,
@@ -31,44 +29,88 @@ import {
IconShield, IconShield,
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// --------------------------------------------------------------------------- /** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
// Mock data — replace with real API interface SeamanBookOverview {
// --------------------------------------------------------------------------- application: {
const ELIGIBILITY = { id: string;
hasProfile: true, applicationId: string;
hasNationalId: true, status: string;
hasMedicalCert: true, submittedAt: string;
medicalExpiry: '2026-03-14', } | null;
bstComplete: true, book: {
bstItems: [ id: string;
{ label: 'Personal Survival Techniques (PST)', done: true }, issuedDate: string;
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true }, expiryDate: string;
{ label: 'Elementary First Aid (EFA)', done: true }, status: string;
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: true }, } | null;
{ label: 'Sexual Harassment Prevention', done: true }, eligibility: {
], hasProfile: boolean;
}; hasSeafarerNumber: boolean;
hasMedical: boolean;
const MOCK_APPLICATION: SeamanBookApp | null = null; medicalExpiry: string | null;
bstComplete: boolean;
interface SeamanBookApp { bstModules: { key: string; label: string; done: boolean }[];
id: string; };
submittedAt: string; eligible: boolean;
status: string;
remarks: string;
timeline: { date: string | null; event: string; done: 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<string, string> = { const STATUS_COLOR: Record<string, string> = {
'Under Review': 'yellow', DRAFT: 'gray',
'Approved': 'teal', SUBMITTED: 'blue',
'Rejected': 'red', UNDER_REVIEW: 'yellow',
'Correction Required': 'orange', UNDER_EVALUATION: 'yellow',
'Ready for Collection': 'blue', 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 }) { function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
return ( return (
<Group gap="xs"> <Group gap="xs">
@@ -85,27 +127,23 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function SeamanBookPage() { export function SeamanBookPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length; const { data, isLoading } = useApiQuery<SeamanBookOverview>({
const isEligible = url: '/seaman-book/my',
ELIGIBILITY.hasProfile && method: 'GET',
ELIGIBILITY.hasNationalId && });
ELIGIBILITY.hasMedicalCert &&
ELIGIBILITY.bstComplete;
const handleApply = async () => { const application = data?.application ?? null;
setSubmitting(true); const eligibility = data?.eligibility;
await new Promise((r) => setTimeout(r, 1400)); const bstItems = eligibility?.bstModules ?? [];
setSubmitting(false); const bstDone = bstItems.filter((b) => b.done).length;
setSubmitted(true);
notify.success('Seaman Book application submitted successfully! Reference: SB-APP-2024-002');
};
const activeStep = MOCK_APPLICATION // The server decides: the same checklist gates the submission, so a screen
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1 // that judged eligibility for itself could offer a button the API refuses.
: -1; const isEligible = data?.eligible ?? false;
const submitted = Boolean(application);
const activeStep = stageIndexFor(application?.status);
return ( return (
<Stack gap="md"> <Stack gap="md">
@@ -119,7 +157,7 @@ export function SeamanBookPage() {
</div> </div>
{/* Active application status */} {/* Active application status */}
{MOCK_APPLICATION && ( {application && (
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm"> <Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs"> <Group gap="xs">
@@ -127,36 +165,43 @@ export function SeamanBookPage() {
<IconBook2 size={18} /> <IconBook2 size={18} />
</ThemeIcon> </ThemeIcon>
<div> <div>
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text> <Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">Submitted {MOCK_APPLICATION.submittedAt}</Text> <Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)}
</Text>
</div> </div>
</Group> </Group>
<Badge color={STATUS_COLOR[MOCK_APPLICATION.status] ?? 'gray'} variant="light" size="lg"> <Badge
{MOCK_APPLICATION.status} color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge> </Badge>
</Group> </Group>
{MOCK_APPLICATION.remarks && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
</Alert>
)}
{/* Progress stepper */} {/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal"> <Stepper active={activeStep} size="sm" color="teal">
{MOCK_APPLICATION.timeline.map((step, i) => ( {STAGES.map((stage, i) => (
<Stepper.Step <Stepper.Step
key={i} key={stage.label}
label={step.event} label={stage.label}
description={step.date ?? 'Pending'} description={i <= activeStep ? 'Done' : 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />} icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/> />
))} ))}
</Stepper> </Stepper>
{MOCK_APPLICATION.status === 'Ready for Collection' && ( {data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md"> <Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book is ready. Please visit the EMA office to collect it. Bring your National ID. Your Seaman Book <strong>{data.book.id}</strong> has been issued.
Please visit the EMA office to collect it, bringing your National ID.
</Alert> </Alert>
)} )}
</Paper> </Paper>
@@ -175,19 +220,39 @@ export function SeamanBookPage() {
</Group> </Group>
<Stack gap="sm"> <Stack gap="sm">
<EligibilityItem label="Profile completed (name, DOB, nationality)" ok={ELIGIBILITY.hasProfile} /> <EligibilityItem
<EligibilityItem label="National ID / Fayda uploaded" ok={ELIGIBILITY.hasNationalId} /> label="Profile completed (name, DOB, nationality)"
<EligibilityItem label="Valid medical certificate uploaded" ok={ELIGIBILITY.hasMedicalCert} /> ok={Boolean(eligibility?.hasProfile)}
/>
<EligibilityItem
label="Registered seafarer number issued"
ok={Boolean(eligibility?.hasSeafarerNumber)}
/>
<EligibilityItem
label={
eligibility?.medicalExpiry
? `Valid medical certificate (expires ${formatDate(eligibility.medicalExpiry)})`
: 'Valid medical certificate uploaded'
}
ok={Boolean(eligibility?.hasMedical)}
/>
<Divider label="Basic Safety Training (all 5 required)" labelPosition="left" my={4} /> <Divider
{ELIGIBILITY.bstItems.map((item) => ( label={`Basic Safety Training (all ${bstItems.length || 5} required)`}
<EligibilityItem key={item.label} label={item.label} ok={item.done} /> labelPosition="left"
my={4}
/>
{bstItems.map((item) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
))} ))}
{!isEligible && ( {!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm"> <Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs"> <Text fz="xs">
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).`
: ''}
</Text> </Text>
</Alert> </Alert>
)} )}
@@ -266,7 +331,6 @@ export function SeamanBookPage() {
<Button <Button
leftSection={<IconBook2 size={16} />} leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')} onClick={() => navigate('/seaman-book/apply')}
loading={submitting}
disabled={!isEligible} disabled={!isEligible}
size="md" size="md"
> >

View File

@@ -35,6 +35,8 @@ import { ExamsPage } from "./features/exams/pages/ExamsPage";
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 { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage"; import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
import { MedicalCertificatePage } from "./features/medical/pages/MedicalCertificatePage";
import { BasicSafetyTrainingPage } from "./features/basic-safety-training/pages/BasicSafetyTrainingPage";
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage"; import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
// Phase 2 — CoC / CoP // Phase 2 — CoC / CoP
@@ -206,6 +208,22 @@ export const router = createBrowserRouter([
</RequirePermission> </RequirePermission>
), ),
}, },
{
path: "/medical",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_MEDICAL]}>
<MedicalCertificatePage />
</RequirePermission>
),
},
{
path: "/basic-safety-training",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_CERTIFICATES]}>
<BasicSafetyTrainingPage />
</RequirePermission>
),
},
{ path: "/notifications", element: <NotificationsPage /> }, { path: "/notifications", element: <NotificationsPage /> },
// Phase 2 — CoC / CoP // Phase 2 — CoC / CoP