mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
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>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { useApiQuery } from '@ema-platform/api';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
FileButton,
|
|
Group,
|
|
Paper,
|
|
Progress,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Timeline,
|
|
Title,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconAlertCircle,
|
|
IconAlertTriangle,
|
|
IconCalendar,
|
|
IconCheck,
|
|
IconCircleCheck,
|
|
IconDownload,
|
|
IconFileDescription,
|
|
IconHeart,
|
|
IconInfoCircle,
|
|
IconTrash,
|
|
IconUpload,
|
|
} from '@tabler/icons-react';
|
|
import { notify } from '@ema-platform/ui';
|
|
|
|
interface MedicalCert {
|
|
id: string;
|
|
issuedBy: string;
|
|
issuedDate: string;
|
|
expiryDate: string;
|
|
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
|
|
restrictions: 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> = {
|
|
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
|
|
};
|
|
|
|
function daysUntil(dateStr: string): number {
|
|
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
export function MedicalCertificatePage() {
|
|
// 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('');
|
|
const [expiryDate, setExpiryDate] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const resetRef = useRef<() => void>(null);
|
|
|
|
// 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;
|
|
|
|
const handleSubmit = async () => {
|
|
if (!uploadedFile || !issuedDate || !expiryDate) {
|
|
notify.error('Please fill all fields and upload the certificate file.');
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
setSubmitting(false);
|
|
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
|
|
setUploadedFile(null);
|
|
setDoctorName('');
|
|
setIssuedDate('');
|
|
setExpiryDate('');
|
|
resetRef.current?.();
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{/* Header */}
|
|
<div>
|
|
<Title order={3}>Medical Certificate</Title>
|
|
<Text fz="sm" c="dimmed">
|
|
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
|
|
</Text>
|
|
</div>
|
|
|
|
{/* Validity alert */}
|
|
{current && days <= 90 && days > 0 && (
|
|
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
|
|
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
|
|
Please visit an EMA-approved medical centre and upload your renewed certificate below.
|
|
</Alert>
|
|
)}
|
|
{current && days <= 0 && (
|
|
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
|
|
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
|
|
</Alert>
|
|
)}
|
|
{!current && (
|
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
|
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
|
|
</Alert>
|
|
)}
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
{/* Current certificate */}
|
|
<Paper withBorder radius="lg" p="lg">
|
|
<Group mb="md" gap="xs">
|
|
<ThemeIcon variant="light" color="red" size={36} radius="md">
|
|
<IconHeart size={18} />
|
|
</ThemeIcon>
|
|
<Text fw={700}>Current Certificate</Text>
|
|
</Group>
|
|
|
|
{current ? (
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
|
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
|
|
<Text fz="sm">{current.id}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
|
|
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
|
|
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
|
|
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
|
|
{formatDate(current.expiryDate)}
|
|
</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
|
|
<Text fz="sm">{current.restrictions}</Text>
|
|
</Group>
|
|
|
|
{/* Validity bar */}
|
|
<Box mt="xs">
|
|
<Group justify="space-between" mb={4}>
|
|
<Text fz="xs" c="dimmed">Validity remaining</Text>
|
|
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
|
|
</Group>
|
|
<Progress
|
|
value={progressVal}
|
|
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
|
|
radius="xl"
|
|
size="sm"
|
|
/>
|
|
</Box>
|
|
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
leftSection={<IconDownload size={13} />}
|
|
mt="xs"
|
|
>
|
|
Download Certificate
|
|
</Button>
|
|
</Stack>
|
|
) : (
|
|
<Box ta="center" py="xl">
|
|
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
|
<IconFileDescription size={22} />
|
|
</ThemeIcon>
|
|
<Text fz="sm" c="dimmed">No certificate on record</Text>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Upload new certificate */}
|
|
<Paper withBorder radius="lg" p="lg">
|
|
<Group mb="md" gap="xs">
|
|
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
|
<IconUpload size={18} />
|
|
</ThemeIcon>
|
|
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
|
|
</Group>
|
|
|
|
<Stack gap="sm">
|
|
<TextInput
|
|
label="Issuing Doctor / Medical Centre"
|
|
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
|
|
value={doctorName}
|
|
onChange={(e) => setDoctorName(e.currentTarget.value)}
|
|
size="sm"
|
|
/>
|
|
<SimpleGrid cols={2} spacing="sm">
|
|
<TextInput
|
|
label="Issue Date"
|
|
type="date"
|
|
value={issuedDate}
|
|
onChange={(e) => setIssuedDate(e.currentTarget.value)}
|
|
size="sm"
|
|
/>
|
|
<TextInput
|
|
label="Expiry Date"
|
|
type="date"
|
|
value={expiryDate}
|
|
onChange={(e) => setExpiryDate(e.currentTarget.value)}
|
|
size="sm"
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<div>
|
|
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
|
{uploadedFile ? (
|
|
<Card withBorder radius="sm" p="xs">
|
|
<Group gap="xs">
|
|
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
|
|
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
|
|
<IconTrash size={13} />
|
|
</Button>
|
|
</Group>
|
|
</Card>
|
|
) : (
|
|
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
|
|
{(props) => (
|
|
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
|
Choose File (PDF / JPG / PNG, max 5MB)
|
|
</Button>
|
|
)}
|
|
</FileButton>
|
|
)}
|
|
</div>
|
|
|
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
|
<Text fz="xs">
|
|
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
|
|
Notifications will be sent by email and SMS.
|
|
</Text>
|
|
</Alert>
|
|
|
|
<Button
|
|
leftSection={<IconCheck size={15} />}
|
|
onClick={handleSubmit}
|
|
loading={submitting}
|
|
disabled={!uploadedFile || !issuedDate || !expiryDate}
|
|
>
|
|
Submit for Verification
|
|
</Button>
|
|
</Stack>
|
|
</Paper>
|
|
</SimpleGrid>
|
|
|
|
{/* Notification schedule */}
|
|
<Paper withBorder radius="lg" p="lg">
|
|
<Group mb="md" gap="xs">
|
|
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
|
<IconCalendar size={18} />
|
|
</ThemeIcon>
|
|
<Text fw={700}>Expiry Notification Schedule</Text>
|
|
</Group>
|
|
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
|
|
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
|
|
<Text fz="xs" c="dimmed">First reminder — time to book your medical examination</Text>
|
|
</Timeline.Item>
|
|
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
|
|
<Text fz="xs" c="dimmed">Second reminder — urgent renewal required</Text>
|
|
</Timeline.Item>
|
|
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
|
|
<Text fz="xs" c="dimmed">Final reminder — certificate expires very soon</Text>
|
|
</Timeline.Item>
|
|
</Timeline>
|
|
</Paper>
|
|
|
|
{/* History */}
|
|
{history.length > 0 && (
|
|
<Paper withBorder radius="lg" p="lg">
|
|
<Text fw={700} mb="md">Certificate History</Text>
|
|
<Stack gap="xs">
|
|
{history.map((cert) => (
|
|
<Card key={cert.id} withBorder radius="sm" p="sm">
|
|
<Group justify="space-between" wrap="wrap" gap="xs">
|
|
<Group gap="sm">
|
|
<ThemeIcon variant="light" color="gray" size={32} radius="md">
|
|
<IconFileDescription size={16} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Text fz="sm" fw={600}>{cert.id}</Text>
|
|
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}</Text>
|
|
</div>
|
|
</Group>
|
|
<Group gap="xs">
|
|
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
|
|
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
|
|
</Group>
|
|
</Group>
|
|
</Card>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|