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 { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
@@ -50,12 +51,22 @@ const STATUS_COLOR: Record<string, string> = {
'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<BSTRecord | null>(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<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 isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0;
@@ -398,15 +420,37 @@ export function BasicSafetyTrainingPage() {
</ThemeIcon>
}
>
{BST_COMPONENTS.map((c) => (
<List.Item key={c.short}>
{BST_COMPONENTS.map((c) => {
const done = doneByKey.get(c.short);
return (
<List.Item
key={c.short}
icon={
<ThemeIcon
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>
</Paper>

View File

@@ -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<string, string> = {
@@ -77,7 +67,16 @@ function formatDate(dateStr: string): string {
// Component
// ---------------------------------------------------------------------------
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 [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() {
</Paper>
{/* History */}
{MOCK_HISTORY.length > 0 && (
{history.length > 0 && (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs">
{MOCK_HISTORY.map((cert) => (
{history.map((cert) => (
<Card key={cert.id} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="wrap" gap="xs">
<Group gap="sm">

View File

@@ -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 {
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
interface SeamanBookOverview {
application: {
id: string;
submittedAt: string;
applicationId: string;
status: string;
remarks: string;
timeline: { date: string | null; event: string; done: boolean }[];
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<string, string> = {
'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 (
<Group gap="xs">
@@ -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<SeamanBookOverview>({
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 (
<Stack gap="md">
@@ -119,7 +157,7 @@ export function SeamanBookPage() {
</div>
{/* Active application status */}
{MOCK_APPLICATION && (
{application && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
@@ -127,36 +165,43 @@ export function SeamanBookPage() {
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text>
<Text fz="xs" c="dimmed">Submitted {MOCK_APPLICATION.submittedAt}</Text>
<Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[MOCK_APPLICATION.status] ?? 'gray'} variant="light" size="lg">
{MOCK_APPLICATION.status}
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</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 */}
<Stepper active={activeStep} size="sm" color="teal">
{MOCK_APPLICATION.timeline.map((step, i) => (
{STAGES.map((stage, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{MOCK_APPLICATION.status === 'Ready for Collection' && (
{data?.book && (
<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>
)}
</Paper>
@@ -175,19 +220,39 @@ export function SeamanBookPage() {
</Group>
<Stack gap="sm">
<EligibilityItem label="Profile completed (name, DOB, nationality)" ok={ELIGIBILITY.hasProfile} />
<EligibilityItem label="National ID / Fayda uploaded" ok={ELIGIBILITY.hasNationalId} />
<EligibilityItem label="Valid medical certificate uploaded" ok={ELIGIBILITY.hasMedicalCert} />
<EligibilityItem
label="Profile completed (name, DOB, nationality)"
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} />
{ELIGIBILITY.bstItems.map((item) => (
<EligibilityItem key={item.label} label={item.label} ok={item.done} />
<Divider
label={`Basic Safety Training (all ${bstItems.length || 5} required)`}
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">
<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>
</Alert>
)}
@@ -266,7 +331,6 @@ export function SeamanBookPage() {
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
loading={submitting}
disabled={!isEligible}
size="md"
>

View File

@@ -35,6 +35,8 @@ import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
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";
// Phase 2 — CoC / CoP
@@ -206,6 +208,22 @@ export const router = createBrowserRouter([
</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 /> },
// Phase 2 — CoC / CoP