mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Paper,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
Tooltip,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import { notifications } from '@mantine/notifications';
|
|
import {
|
|
IconArrowRight,
|
|
IconBook2,
|
|
IconCertificate,
|
|
IconClock,
|
|
IconDownload,
|
|
IconEye,
|
|
IconInfoCircle,
|
|
IconShieldCheck,
|
|
} from '@tabler/icons-react';
|
|
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
|
|
import { useApiQuery } from '@ema-platform/api';
|
|
import {
|
|
useGetMySeaServiceRecordsQuery,
|
|
useGetMyMedicalCertificatesQuery,
|
|
} from '@ema-platform/api';
|
|
import { PdfPreviewModal } from '@ema-platform/ui';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock data
|
|
// ---------------------------------------------------------------------------
|
|
/** What `/certificates/my` returns: what is held, and what is still in flight. */
|
|
interface CertificatesOverview {
|
|
certificates: {
|
|
id: string;
|
|
type: string;
|
|
issued: string;
|
|
expiry: string;
|
|
status: string;
|
|
}[];
|
|
applications: {
|
|
id: string;
|
|
applicationId: string;
|
|
type: string;
|
|
submitted: string;
|
|
status: string;
|
|
}[];
|
|
}
|
|
|
|
/**
|
|
* Badge colour per workflow status.
|
|
*
|
|
* Keyed by the values the API reports rather than display strings, so an
|
|
* unmapped status falls back to grey instead of rendering colourless.
|
|
*/
|
|
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',
|
|
ELIGIBILITY_APPROVED: 'teal',
|
|
EXAM_PAYMENT_PENDING: 'orange',
|
|
EXAM_PAID: 'blue',
|
|
EXAM_SCHEDULED: 'indigo',
|
|
EXAM_PASSED: 'teal',
|
|
EXAM_FAILED: 'red',
|
|
APPROVED: 'teal',
|
|
REJECTED: 'red',
|
|
PAYMENT_PENDING: 'orange',
|
|
PAID: 'blue',
|
|
PAYMENT_CONFIRMED: 'blue',
|
|
CERTIFICATE_ISSUED: 'teal',
|
|
COMPLETED: 'teal',
|
|
ACTIVE: 'teal',
|
|
EXPIRED: 'red',
|
|
SUSPENDED: 'orange',
|
|
CANCELLED: 'gray',
|
|
SUPERSEDED: 'gray',
|
|
};
|
|
|
|
/** Turns `EXAM_PAYMENT_PENDING` into something a person reads. */
|
|
function humanStatus(status: string): string {
|
|
return status
|
|
.toLowerCase()
|
|
.split('_')
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return '—';
|
|
return new Date(value).toLocaleDateString('en-GB', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
const API_BASE =
|
|
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
|
'http://localhost:3000/api';
|
|
|
|
async function generateCertificate(profileId: string): Promise<Blob> {
|
|
const token = authStorage.getToken();
|
|
if (!token) throw new Error('No auth token found');
|
|
const res = await fetch(
|
|
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
|
|
return res.blob();
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, filename: string) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export function CertificatesPage() {
|
|
const navigate = useNavigate();
|
|
const profileId = authStorage.getProfileId() ?? '';
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [previewTitle, setPreviewTitle] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const { data } = useApiQuery<CertificatesOverview>({
|
|
url: '/certificates/my',
|
|
method: 'GET',
|
|
});
|
|
const certificates = data?.certificates ?? [];
|
|
const applications = data?.applications ?? [];
|
|
|
|
// eligibleForCoc is computed server-side (seafarer registration approved,
|
|
// plus a verified sea service record and a verified medical certificate)
|
|
// so the button and the API's own eligibility check can never disagree.
|
|
// The three queries below only build the human-readable reason list for
|
|
// the tooltip/banner — the gate itself is the one boolean.
|
|
const { profile, eligibleForCoc: canApply } = useCurrentProfile();
|
|
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
|
|
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
|
|
|
|
const seafarerApproved = profile?.seafarerStatus === 'ACTIVE';
|
|
const hasVerifiedSeaService = (seaServiceRecords ?? []).some((r) => r.status === 'VERIFIED');
|
|
const hasVerifiedMedical = (medicalCertificates ?? []).some((c) => c.status === 'VERIFIED');
|
|
|
|
const missingReasons = [
|
|
!seafarerApproved && 'Your seafarer registration is not yet approved.',
|
|
!hasVerifiedSeaService && 'No verified sea service record on file.',
|
|
!hasVerifiedMedical && 'No verified medical certificate on file.',
|
|
].filter((r): r is string => Boolean(r));
|
|
|
|
const openPreview = async (profileId: string, title: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const blob = await generateCertificate(profileId);
|
|
const url = URL.createObjectURL(blob);
|
|
setPreviewTitle(title);
|
|
setPreviewUrl(url);
|
|
} catch (err) {
|
|
notifications.show({
|
|
color: 'red',
|
|
title: 'Error',
|
|
message: err instanceof Error ? err.message : 'Could not generate certificate',
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (profileId: string, title: string) => {
|
|
try {
|
|
const blob = await generateCertificate(profileId);
|
|
downloadBlob(blob, `certificate-${Date.now()}.pdf`);
|
|
notifications.show({
|
|
color: 'teal',
|
|
title: 'Downloaded',
|
|
message: 'Certificate PDF downloaded successfully',
|
|
});
|
|
} catch (err) {
|
|
notifications.show({
|
|
color: 'red',
|
|
title: 'Error',
|
|
message: err instanceof Error ? err.message : 'Could not download certificate',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
|
<div>
|
|
<Title order={3}>Certificates (CoC / CoP)</Title>
|
|
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
|
|
</div>
|
|
<Tooltip
|
|
label={missingReasons.join(' ')}
|
|
disabled={canApply}
|
|
multiline
|
|
w={260}
|
|
events={{ hover: true, focus: true, touch: true }}
|
|
>
|
|
{/* Tooltip needs a hoverable child even while the button itself is
|
|
disabled, so the reason still shows on hover. */}
|
|
<span>
|
|
<Button
|
|
leftSection={<IconShieldCheck size={15} />}
|
|
rightSection={<IconArrowRight size={15} />}
|
|
onClick={() => navigate('/certificates/apply')}
|
|
disabled={!canApply}
|
|
>
|
|
Apply for CoC / CoP
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
{!canApply && (
|
|
<Alert variant="light" color="orange" icon={<IconInfoCircle size={15} />}>
|
|
<Text fz="sm" fw={600}>Not yet eligible to apply</Text>
|
|
<Text fz="xs" c="dimmed">{missingReasons.join(' ')}</Text>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Info banner */}
|
|
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
|
<Group gap="md" wrap="nowrap">
|
|
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconShieldCheck size={24} /></ThemeIcon>
|
|
<Stack gap={2} style={{ flex: 1 }}>
|
|
<Text fw={700} fz="sm">What is a CoC / CoP?</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
|
{[
|
|
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
|
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
|
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
|
|
].map(({ icon: Icon, color, title, desc }) => (
|
|
<Card key={title} withBorder radius="md" p="sm">
|
|
<Group gap="xs" mb={4}>
|
|
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
|
<Text fz="xs" fw={700}>{title}</Text>
|
|
</Group>
|
|
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Group>
|
|
</Paper>
|
|
|
|
{/* Active applications */}
|
|
<Paper withBorder radius="lg" p="xl">
|
|
<Text fw={700} mb="md">My Applications</Text>
|
|
{applications.length === 0 ? (
|
|
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
|
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
|
</Alert>
|
|
) : (
|
|
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
|
<Table.Thead bg="var(--mantine-color-default-hover)">
|
|
<Table.Tr>
|
|
{['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', 'Status', ''].map((h) => (
|
|
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
|
))}
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{applications.map((app) => (
|
|
<Table.Tr key={app.id}>
|
|
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
|
<Table.Td><Text fz="xs" fw={500} maw={220} style={{ lineHeight: 1.3 }}>{app.type}</Text></Table.Td>
|
|
<Table.Td><Text fz="xs">{formatDate(app.submitted)}</Text></Table.Td>
|
|
<Table.Td>
|
|
<Text fz="xs" c="dimmed" maw={200} lh={1.3}>
|
|
{humanStatus(app.status)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge
|
|
color={STATUS_COLOR[app.status] ?? 'gray'}
|
|
variant="light"
|
|
size="sm"
|
|
>
|
|
{humanStatus(app.status)}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text
|
|
fz="xs"
|
|
c="blue"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => navigate(`/applications/${app.applicationId}`)}
|
|
>
|
|
Details
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Issued certificates */}
|
|
<Paper withBorder radius="lg" p="xl">
|
|
<Text fw={700} mb="md">My Certificates</Text>
|
|
{certificates.length === 0 ? (
|
|
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
|
No certificates issued yet.
|
|
</Alert>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
{certificates.map((cert) => (
|
|
<Card key={cert.id} withBorder radius="md" p="md">
|
|
<Group justify="space-between" mb="sm">
|
|
<Group gap="sm">
|
|
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShieldCheck size={18} /></ThemeIcon>
|
|
<div>
|
|
<Text fw={700} fz="sm">{cert.type}</Text>
|
|
<Text fz="xs" c="dimmed">{cert.id}</Text>
|
|
</div>
|
|
</Group>
|
|
<Badge color={STATUS_COLOR[cert.status] ?? "gray"} variant="light">{cert.status}</Badge>
|
|
</Group>
|
|
<Divider mb="sm" />
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{cert.issued}</Text></div>
|
|
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{cert.expiry}</Text></div>
|
|
</SimpleGrid>
|
|
<Group mt="sm" gap="xs">
|
|
<Button size="xs" variant="light" leftSection={loading ? <Loader size={12} /> : <IconEye size={12} />} onClick={() => openPreview(profileId, cert.type)}>View</Button>
|
|
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />} onClick={() => handleDownload(profileId, cert.type)}>Download</Button>
|
|
</Group>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
</Paper>
|
|
|
|
<PdfPreviewModal
|
|
opened={!!previewUrl}
|
|
onClose={() => setPreviewUrl(null)}
|
|
url={previewUrl ?? ''}
|
|
title={previewTitle}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|