Files
emaui/apps/portal/src/app/features/seaman-book/pages/SeamanBookPage.tsx
fitse-yotor 35c7987047 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>
2026-08-17 01:18:59 +03:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useNavigate } from 'react-router-dom';
import { useApiQuery } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
/** 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<string, string> = {
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">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
</Group>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
const navigate = useNavigate();
const { data, isLoading } = useApiQuery<SeamanBookOverview>({
url: '/seaman-book/my',
method: 'GET',
});
const application = data?.application ?? null;
const eligibility = data?.eligibility;
const bstItems = eligibility?.bstModules ?? [];
const bstDone = bstItems.filter((b) => b.done).length;
// 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">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records your sea service and must be
held before joining any vessel.
</Text>
</div>
{/* Active application status */}
{application && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[application.status] ?? 'gray'}
variant="light"
size="lg"
>
{application.status.replaceAll('_', ' ')}
</Badge>
</Group>
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{STAGES.map((stage, i) => (
<Stepper.Step
key={stage.label}
label={stage.label}
description={i <= activeStep ? 'Done' : 'Pending'}
icon={
i <= activeStep ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{data?.book && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
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>
)}
{/* No active application — eligibility + apply */}
{!submitted && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
</Group>
<Stack gap="sm">
<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 ${bstItems.length || 5} required)`}
labelPosition="left"
my={4}
/>
{bstItems.map((item) => (
<EligibilityItem key={item.key} label={item.label} ok={item.done} />
))}
{!isLoading && !isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs">
Complete all requirements above before applying.
{bstItems.length > bstDone
? ` Missing BST: ${bstItems.length - bstDone} certificate(s).`
: ''}
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
</Box>
))}
</SimpleGrid>
</Paper>
</Stack>
);
}