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 = { 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 }).env?.['VITE_BASE_API_URL'] ?? 'http://localhost:3000/api'; async function generateCertificate(profileId: string): Promise { 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(null); const [previewTitle, setPreviewTitle] = useState(''); const [loading, setLoading] = useState(false); const { data } = useApiQuery({ 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 (
Certificates (CoC / CoP) Certificate of Competency and Certificate of Proficiency under STCW
{/* Tooltip needs a hoverable child even while the button itself is disabled, so the reason still shows on hover. */}
{!canApply && ( }> Not yet eligible to apply {missingReasons.join(' ')} )} {/* Info banner */} What is a CoC / CoP? {[ { 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 }) => ( {title} {desc} ))} {/* Active applications */} My Applications {applications.length === 0 ? ( }> No active CoC/CoP applications. Click "Apply for CoC / CoP" to start. ) : ( {['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', 'Status', ''].map((h) => ( {h} ))} {applications.map((app) => ( {app.id} {app.type} {formatDate(app.submitted)} {humanStatus(app.status)} {humanStatus(app.status)} navigate(`/applications/${app.applicationId}`)} > Details ))}
)}
{/* Issued certificates */} My Certificates {certificates.length === 0 ? ( }> No certificates issued yet. ) : ( {certificates.map((cert) => (
{cert.type} {cert.id}
{cert.status}
Issued{cert.issued}
Expires{cert.expiry}
))}
)}
setPreviewUrl(null)} url={previewUrl ?? ''} title={previewTitle} />
); }