mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
343 lines
13 KiB
TypeScript
343 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';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
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?.();
|
|
};
|
|
|
|
const { t } = useTranslation();
|
|
|
|
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>
|
|
);
|
|
}
|