feat(portal): restore Mengestab's client-approved seafarer and vessel UI

Copied verbatim from the pre-override branch so the approved screens are
recoverable at this exact commit before any wiring changes them.

Brings back the richer flows the client signed off: a four-step seafarer
registration wizard with bilingual inputs and an Ethiopic date picker,
the vessel-owner portal (its own register/login/dashboard), ownership
transfer, and the seaman book, certificate, medical and endorsement
screens.

Six of these pages already call an API; ten are mockups carrying
hardcoded data. Both are committed as-is here -- the wiring that follows
is a separate commit so the diff shows exactly what changed from what
the client approved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fitse-yotor
2026-08-16 23:19:08 +03:00
parent f294f67ced
commit eabaae36a0
28 changed files with 7142 additions and 2839 deletions

View File

@@ -0,0 +1,272 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Loader,
Modal,
Paper,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconArrowRight,
IconBook2,
IconCertificate,
IconClock,
IconDownload,
IconEye,
IconInfoCircle,
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage } from '@ema-platform/auth';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_COC_APPS = [
{
id: 'COC-APP-2025-001',
type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
submitted: '2025-03-10',
examDate: '2025-04-15',
examVenue: 'EMA HQ — Addis Ababa',
status: 'Examination Scheduled',
statusColor: 'indigo',
statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
},
{
id: 'COC-APP-2025-005',
type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
submitted: '2025-05-01',
examDate: null,
examVenue: null,
status: 'TRB Inspection',
statusColor: 'yellow',
statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
},
];
const MOCK_CERTIFICATES = [
{
id: 'COC-2023-0042',
type: 'CoC — STCW II/1',
issued: '2023-06-20',
expiry: '2028-06-20',
status: 'Valid',
statusColor: 'teal',
},
];
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/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 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>
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/certificates/apply')}
>
Apply for CoC / CoP
</Button>
</Group>
{/* 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>
{MOCK_COC_APPS.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>
{MOCK_COC_APPS.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">{app.submitted}</Text></Table.Td>
<Table.Td>
{app.examDate
? <><Text fz="xs" fw={500}>{app.examDate}</Text><Text fz="xs" c="dimmed">{app.examVenue}</Text></>
: <Text fz="xs" c="dimmed" maw={200} lh={1.3}>{app.statusNote}</Text>}
</Table.Td>
<Table.Td>
<Badge color={app.statusColor} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Text fz="xs" c="blue" style={{ cursor: 'pointer' }}>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>
{MOCK_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">
{MOCK_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={cert.statusColor} 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>
{/* Preview modal */}
<Modal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
size="95vw"
radius="lg"
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
</Stack>
);
}