Merge pull request #3 from Tria-plc/feature/examination

This commit is contained in:
Eyob T.
2026-07-09 10:31:07 +03:00
99 changed files with 22875 additions and 3492 deletions

View File

@@ -286,12 +286,12 @@ const RULES = [
id: "GLOBAL_NONCE_MARKER",
severity: "CRITICAL",
description:
"Sets a short global marker string (e.g. global['!']='8-3946') as an infection flag / re-execution guard",
"Sets a short global marker string (e.g.
test(src) {
return matchAll(src, [
// global['!'] = '8-3946' or global["x"] = "abc-123"
/global\s*\[\s*['"][^'"]{0,5}['"]\s*\]\s*=\s*['"][0-9!@#$%^&*\-]{3,20}['"]/g,
// global['!']='...' with no spaces (minified form)
//
/global\['[^']{0,5}'\]='[^']{2,20}'/g,
]);
},

1
.gitignore vendored
View File

@@ -30,4 +30,5 @@ temp_interactive_push.bat
apps/backoffice/public/_um/
apps/backoffice/public/tinymce/
local-packages/iamui-extracted/

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "user-management"]
path = user-management
url = git@github.com:Tria-plc/iamui.git

View File

@@ -0,0 +1,255 @@
import {
Badge,
Button,
Card,
Divider,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconChartBar,
IconDownload,
IconHeart,
IconId,
IconShieldCheck,
IconTrendingUp,
IconUsers,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// Mock statistics
// ---------------------------------------------------------------------------
const KPI = [
{ label: 'Total Seafarers', value: 1284, delta: '+12 this month', color: 'blue', icon: IconUsers },
{ label: 'Seaman Books Issued', value: 1102, delta: '85.8% of total', color: 'teal', icon: IconBook2 },
{ label: 'BTC Issued', value: 1098, delta: '85.5% of total', color: 'teal', icon: IconCertificate },
{ label: 'BSID Issued', value: 874, delta: '68.1% of total', color: 'violet', icon: IconId },
{ label: 'CoC / CoP Issued', value: 342, delta: '26.6% of total', color: 'orange', icon: IconShieldCheck },
{ label: 'Medical Expiring (90d)',value: 41, delta: 'Action required', color: 'red', icon: IconHeart },
];
const MONTHLY_APPS = [
{ month: 'Jan', seamanBook: 18, coc: 4 },
{ month: 'Feb', seamanBook: 22, coc: 6 },
{ month: 'Mar', seamanBook: 28, coc: 9 },
{ month: 'Apr', seamanBook: 31, coc: 12 },
{ month: 'May', seamanBook: 26, coc: 8 },
{ month: 'Jun', seamanBook: 35, coc: 14 },
];
const CERT_DISTRIBUTION = [
{ label: 'Seaman Book', count: 1102, pct: 86, color: 'blue' },
{ label: 'Basic Training Cert', count: 1098, pct: 85, color: 'teal' },
{ label: 'BSID', count: 874, pct: 68, color: 'violet' },
{ label: 'CoC / CoP', count: 342, pct: 27, color: 'orange' },
];
const REVENUE = [
{ cert: 'Seaman Book', apps: 1102, feePerApp: 700, revenue: 771400 },
{ cert: 'BTC', apps: 1098, feePerApp: 400, revenue: 439200 },
{ cert: 'BSID', apps: 874, feePerApp: 250, revenue: 218500 },
{ cert: 'CoC / CoP', apps: 342, feePerApp: 1400, revenue: 478800 },
];
const TOTAL_REVENUE = REVENUE.reduce((s, r) => s + r.revenue, 0);
const EXPIRY_WATCH = [
{ cert: 'Seaman Book', expiring30: 8, expiring90: 23, total: 1102 },
{ cert: 'BTC', expiring30: 6, expiring90: 19, total: 1098 },
{ cert: 'Medical Cert', expiring30: 12, expiring90: 41, total: 1284 },
{ cert: 'CoC / CoP', expiring30: 4, expiring90: 17, total: 342 },
];
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function KpiCard({ label, value, delta, color, icon: Icon }: typeof KPI[0]) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb="xs">
<ThemeIcon variant="light" color={color} size={44} radius="md"><Icon size={22} stroke={1.6} /></ThemeIcon>
<Text fz="xl" fw={800}>{value.toLocaleString()}</Text>
</Group>
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
<Text fz="xs" c="dimmed" mt={2}>{delta}</Text>
</Card>
);
}
// Simple bar chart using divs
function BarChart() {
const max = Math.max(...MONTHLY_APPS.map((m) => m.seamanBook + m.coc));
return (
<Stack gap="sm">
{MONTHLY_APPS.map((m) => (
<Group key={m.month} gap="sm" align="center" wrap="nowrap">
<Text fz="xs" c="dimmed" w={28} style={{ flexShrink: 0 }}>{m.month}</Text>
<div style={{ flex: 1, position: 'relative', height: 22 }}>
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${(m.seamanBook / max) * 100}%`, background: 'var(--mantine-color-blue-4)', borderRadius: 4 }} />
<div style={{ position: 'absolute', left: `${(m.seamanBook / max) * 100}%`, top: 0, height: '100%', width: `${(m.coc / max) * 100}%`, background: 'var(--mantine-color-orange-4)', borderRadius: '0 4px 4px 0' }} />
</div>
<Text fz="xs" fw={600} w={40} style={{ flexShrink: 0 }}>{m.seamanBook + m.coc}</Text>
</Group>
))}
<Group gap="md" mt="xs">
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-blue-4)' }} /><Text fz="xs" c="dimmed">Seaman Book</Text></Group>
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-orange-4)' }} /><Text fz="xs" c="dimmed">CoC / CoP</Text></Group>
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function AnalyticsPage() {
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Analytics & Reports</Title>
<Text fz="sm" c="dimmed">Overview of seafarer registrations, certificate issuance, and revenue mock data for demonstration</Text>
</div>
<Button variant="default" size="sm" leftSection={<IconDownload size={14} />}>
Export PDF
</Button>
</Group>
{/* KPIs */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 6 }} spacing="sm">
{KPI.map((k) => <KpiCard key={k.label} {...k} />)}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Monthly applications bar chart */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="xs">
<ThemeIcon size={28} radius="md" color="blue" variant="light"><IconChartBar size={15} /></ThemeIcon>
<Text fw={700} fz="sm">Monthly Applications (2025)</Text>
</Group>
</Group>
<BarChart />
</Paper>
{/* Certificate distribution */}
<Paper withBorder radius="lg" p="xl">
<Group gap="xs" mb="lg">
<ThemeIcon size={28} radius="md" color="teal" variant="light"><IconTrendingUp size={15} /></ThemeIcon>
<Text fw={700} fz="sm">Certificate Distribution</Text>
</Group>
<Stack gap="md">
{CERT_DISTRIBUTION.map((c) => (
<div key={c.label}>
<Group justify="space-between" mb={4}>
<Group gap="xs">
<Badge color={c.color} variant="light" size="sm">{c.label}</Badge>
</Group>
<Group gap="xs">
<Text fz="xs" fw={600}>{c.count.toLocaleString()}</Text>
<Text fz="xs" c="dimmed">{c.pct}%</Text>
</Group>
</Group>
<Progress value={c.pct} color={c.color} radius="xl" size="sm" />
</div>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Revenue breakdown */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="md">
<Text fw={700} fz="sm">Revenue Breakdown by Certificate Type</Text>
<Badge size="lg" variant="light" color="blue">Total: ETB {TOTAL_REVENUE.toLocaleString()}</Badge>
</Group>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate Type', 'Applications', 'Fee per App', 'Total Revenue', '% of Revenue'].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>
{REVENUE.map((r) => (
<Table.Tr key={r.cert}>
<Table.Td><Text fz="sm" fw={600}>{r.cert}</Text></Table.Td>
<Table.Td><Text fz="sm">{r.apps.toLocaleString()}</Text></Table.Td>
<Table.Td><Text fz="sm">ETB {r.feePerApp.toLocaleString()}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={700} c="blue">ETB {r.revenue.toLocaleString()}</Text></Table.Td>
<Table.Td>
<Group gap="xs">
<Progress value={(r.revenue / TOTAL_REVENUE) * 100} size="sm" radius="xl" style={{ flex: 1, minWidth: 60 }} />
<Text fz="xs" c="dimmed">{((r.revenue / TOTAL_REVENUE) * 100).toFixed(1)}%</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td><Text fz="sm" fw={700}>Total</Text></Table.Td>
<Table.Td><Text fz="sm" fw={700}>{REVENUE.reduce((s, r) => s + r.apps, 0).toLocaleString()}</Text></Table.Td>
<Table.Td />
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {TOTAL_REVENUE.toLocaleString()}</Text></Table.Td>
<Table.Td />
</Table.Tr>
</Table.Tfoot>
</Table>
</Paper>
{/* Expiry watchlist */}
<Paper withBorder radius="lg" p="xl">
<Group gap="xs" mb="md">
<ThemeIcon size={28} radius="md" color="red" variant="light"><IconHeart size={15} /></ThemeIcon>
<Text fw={700} fz="sm">Expiry Watchlist</Text>
</Group>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate', 'Total Issued', 'Expiring in 30 days', 'Expiring in 90 days', '% at Risk'].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>
{EXPIRY_WATCH.map((e) => (
<Table.Tr key={e.cert}>
<Table.Td><Text fz="sm" fw={600}>{e.cert}</Text></Table.Td>
<Table.Td><Text fz="sm">{e.total.toLocaleString()}</Text></Table.Td>
<Table.Td>
<Badge color={e.expiring30 > 5 ? 'red' : 'orange'} variant="light" size="sm">{e.expiring30}</Badge>
</Table.Td>
<Table.Td>
<Badge color={e.expiring90 > 20 ? 'orange' : 'yellow'} variant="light" size="sm">{e.expiring90}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Progress
value={(e.expiring90 / e.total) * 100}
color={(e.expiring90 / e.total) > 0.1 ? 'red' : 'orange'}
size="sm"
radius="xl"
style={{ flex: 1, minWidth: 60 }}
/>
<Text fz="xs" c="dimmed">{((e.expiring90 / e.total) * 100).toFixed(1)}%</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,446 @@
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconBook2,
IconCertificate,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconExternalLink,
IconEye,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconShieldCheck,
IconUser,
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const FEES = [
{ group: 'Seaman Book', label: 'Application Fee', amount: 500 },
{ group: 'Seaman Book', label: 'Document Verification Fee', amount: 200 },
{ group: 'Basic Training Certificate (BTC)', label: 'Application Fee', amount: 300 },
{ group: 'Basic Training Certificate (BTC)', label: 'Document Verification Fee',amount: 100 },
{ group: 'BSID', label: 'Application Fee', amount: 100 },
];
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
const MOCK_APPLICATION = {
id: 'SB-APP-2025-001',
submittedAt: '2025-05-10',
status: 'pending_review' as const,
applicant: {
fullName: 'Abebe Girma Tadesse',
dob: '1990-04-15',
nationality: 'Ethiopian',
idNumber: 'ID-ETH-2024-001',
phone: '+251 91 234 5678',
email: 'abebe.girma@email.com',
address: 'Bole, Addis Ababa, Ethiopia',
height: '178 cm',
bloodType: 'O+',
hairColor: 'Black',
eyeColor: 'Dark Brown',
seafarerNumber: 'SEAF-2024-00142',
},
bst: [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-15', expiryDate: '2029-01-15', fileName: 'pst_cert.pdf', fileType: 'pdf' },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-16', expiryDate: '2029-01-16', fileName: 'fpff_cert.pdf', fileType: 'pdf' },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: '', fileName: 'efa_cert.jpg', fileType: 'image' },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: '', fileName: 'pssr_cert.jpg', fileType: 'image' },
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: '', fileName: 'shpt_cert.jpg', fileType: 'image' },
],
medical: {
certNumber: 'MC-2024-009',
issuer: 'EMA Medical Centre — Addis Ababa',
issueDate: '2024-03-20',
expiryDate: '2026-03-20',
fileName: 'medical_cert.pdf',
fileType: 'pdf',
},
payment: {
method: 'CBE Bank Transfer',
ref: 'CBE-TXN-20240510-001',
date: '2025-05-10',
status: 'confirmed' as 'confirmed' | 'pending' | 'rejected',
receiptFileName: 'payment_receipt.pdf',
receiptFileType: 'pdf',
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
pending_review: 'yellow', approved: 'teal', correction_required: 'orange', rejected: 'red',
};
const STATUS_LABEL: Record<string, string> = {
pending_review: 'Pending Review', approved: 'Approved', correction_required: 'Correction Required', rejected: 'Rejected',
};
const PAYMENT_COLOR = { confirmed: 'teal', pending: 'yellow', rejected: 'red' } as const;
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
// ---------------------------------------------------------------------------
// Inline document viewer
// ---------------------------------------------------------------------------
function DocViewer({ fileName, fileType, label }: { fileName: string; fileType: string; label: string }) {
const [open, setOpen] = useState(false);
const url = getDocUrl(fileName);
return (
<>
<Card withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" mb="sm">
<ThemeIcon size="md" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'} radius="md">
<IconFileDescription size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600}>{label}</Text>
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
</div>
<Badge size="xs" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'}>{fileType.toUpperCase()}</Badge>
</Group>
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
{fileType === 'pdf'
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Box>
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View Full Document
</Button>
</Card>
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
{fileType === 'pdf'
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Modal>
</>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function SectionLabel({ children }: { children: string }) {
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function ApplicationReviewPage() {
const navigate = useNavigate();
const app = MOCK_APPLICATION;
const [status, setStatus] = useState<string>(app.status);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [noteModalOpen, setNoteModalOpen] = useState(false);
const [noteAction, setNoteAction] = useState<'correction' | 'reject' | null>(null);
const [note, setNote] = useState('');
const performAction = async (action: string, label: string, newStatus: string) => {
setActionLoading(action);
await new Promise((r) => setTimeout(r, 1200));
setStatus(newStatus);
setActionLoading(null);
notify.success(`Application ${label}`);
if (noteModalOpen) setNoteModalOpen(false);
};
const openNote = (action: 'correction' | 'reject') => {
setNoteAction(action);
setNote('');
setNoteModalOpen(true);
};
// Group fees by category for display
const feeGroups = FEES.reduce<Record<string, typeof FEES>>((acc, f) => {
(acc[f.group] = acc[f.group] || []).push(f);
return acc;
}, {});
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/seaman-book-queue')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>Application Review</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{app.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {app.submittedAt}</Text>
</Group>
</div>
</Group>
<Badge size="lg" variant="light" color={STATUS_COLOR[status] ?? 'gray'}>
{STATUS_LABEL[status] ?? status}
</Badge>
</Group>
{/* Action bar */}
{status === 'pending_review' && (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Group gap="sm" justify="flex-end">
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
<Button size="sm" color="orange" variant="light" leftSection={<IconAlertTriangle size={15} />} onClick={() => openNote('correction')}>
Request Correction
</Button>
<Button size="sm" color="red" variant="light" leftSection={<IconX size={15} />} onClick={() => openNote('reject')}>
Reject
</Button>
<Button size="sm" color="teal" leftSection={<IconCheck size={15} />} onClick={() => performAction('approve', 'approved', 'approved')} loading={actionLoading === 'approve'}>
Approve Application
</Button>
</Group>
</Paper>
)}
{status !== 'pending_review' && (
<Alert variant="light" color={STATUS_COLOR[status] ?? 'gray'}
icon={status === 'approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}>
This application has been <strong>{STATUS_LABEL[status] ?? status}</strong>.
</Alert>
)}
<Tabs defaultValue="profile" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant Profile</Tabs.Tab>
<Tabs.Tab value="bst" leftSection={<IconShieldCheck size={16} />}>BST Certificates</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeart size={16} />}>Medical</Tabs.Tab>
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
<Tabs.Tab value="issuance" leftSection={<IconCertificate size={16} />}>Issuance</Tabs.Tab>
</Tabs.List>
{/* ── Applicant Profile ─────────────────────────────────────── */}
<Tabs.Panel value="profile">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{app.applicant.fullName}</Text>
<Text fz="sm" c="dimmed">Seafarer No: {app.applicant.seafarerNumber}</Text>
</div>
</Group>
<Divider mb="md" />
<SectionLabel>Personal Information</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="Date of Birth" value={app.applicant.dob} />
<InfoRow label="Nationality" value={app.applicant.nationality} />
<InfoRow label="National ID" value={app.applicant.idNumber} />
<InfoRow label="Phone" value={app.applicant.phone} />
<InfoRow label="Email" value={app.applicant.email} />
<InfoRow label="Address" value={app.applicant.address} />
</SimpleGrid>
<Divider mb="md" />
<SectionLabel>Physical Characteristics</SectionLabel>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label="Height" value={app.applicant.height} />
<InfoRow label="Blood Type" value={app.applicant.bloodType} />
<InfoRow label="Hair Color" value={app.applicant.hairColor} />
<InfoRow label="Eye Color" value={app.applicant.eyeColor} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── BST Certificates ──────────────────────────────────────── */}
<Tabs.Panel value="bst">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Verify all 5 BST training certificates. PST and FPFF have 5-year renewal check expiry dates are valid.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{app.bst.map((cert) => (
<Paper key={cert.key} withBorder radius="lg" p="md">
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
<IconShieldCheck size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{cert.short}</Text>
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
</div>
</Group>
<Stack gap={4} mb="sm">
<InfoRow label="Certificate No." value={cert.certNumber} />
<InfoRow label="Issuer" value={cert.issuer} />
<InfoRow label="Issue Date" value={cert.issueDate} />
{cert.expiryDate && <InfoRow label="Expiry Date" value={cert.expiryDate} />}
</Stack>
<DocViewer fileName={cert.fileName} fileType={cert.fileType} label={`${cert.short} Certificate`} />
</Paper>
))}
</SimpleGrid>
</Stack>
</Tabs.Panel>
{/* ── Medical ───────────────────────────────────────────────── */}
<Tabs.Panel value="medical">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="pink" radius="lg"><IconHeart size={22} stroke={1.5} /></ThemeIcon>
<Text fw={700} fz="lg">Medical Certificate</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="Certificate No." value={app.medical.certNumber} />
<InfoRow label="Issuing Centre" value={app.medical.issuer} />
<InfoRow label="Issue Date" value={app.medical.issueDate} />
<InfoRow label="Expiry Date" value={app.medical.expiryDate} />
</SimpleGrid>
<Box style={{ maxWidth: rem(420) }}>
<DocViewer fileName={app.medical.fileName} fileType={app.medical.fileType} label="Medical Certificate" />
</Box>
</Paper>
</Tabs.Panel>
{/* ── Payment ───────────────────────────────────────────────── */}
<Tabs.Panel value="payment">
<Stack gap="md">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Payment</Text>
<Badge size="sm" variant="light" color={PAYMENT_COLOR[app.payment.status]} mt={2}>
{app.payment.status.charAt(0).toUpperCase() + app.payment.status.slice(1)}
</Badge>
</div>
</Group>
{/* Fee breakdown grouped */}
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
<Text fw={700} fz="sm" mb="md">Fee Breakdown</Text>
{Object.entries(feeGroups).map(([group, fees], gi) => (
<div key={group}>
{gi > 0 && <Divider my="sm" />}
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>{group}</Text>
{fees.map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
</div>
))}
<Divider mt="sm" mb="xs" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total</Text>
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mb="lg">
<InfoRow label="Payment Method" value={app.payment.method} />
<InfoRow label="Transaction Reference" value={app.payment.ref} />
<InfoRow label="Payment Date" value={app.payment.date} />
</SimpleGrid>
<Box style={{ maxWidth: rem(420) }}>
<DocViewer fileName={app.payment.receiptFileName} fileType={app.payment.receiptFileType} label="Payment Receipt" />
</Box>
</Paper>
</Stack>
</Tabs.Panel>
{/* ── Issuance ──────────────────────────────────────────────── */}
<Tabs.Panel value="issuance">
<Stack gap="md">
<Alert variant="light" color={status === 'approved' ? 'teal' : 'yellow'} icon={<IconInfoCircle size={17} />}>
{status === 'approved'
? 'Application approved. The documents below will be generated and dispatched to the applicant.'
: 'Approve the application to trigger document issuance.'}
</Alert>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, color: 'blue', label: 'Seaman Book', desc: 'Official EMA seafarer identity book', validity: '5 years' },
{ icon: IconCertificate,color: 'teal', label: 'Basic Training Certificate (BTC)',desc: 'EMA-issued BTC for all 5 BST courses', validity: '5 years' },
{ icon: IconShieldCheck,color: 'violet',label: 'BSID Card', desc: 'Biometric Seafarer ID Card', validity: '5 years' },
].map(({ icon: Icon, color, label, desc, validity }) => (
<Card key={label} withBorder radius="lg" p="md"
style={{ borderColor: status === 'approved' ? `var(--mantine-color-${color}-3)` : 'var(--mantine-color-gray-2)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="xl" variant="light" color={status === 'approved' ? color : 'gray'} radius="lg">
<Icon size={22} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{label}</Text>
<Badge size="xs" variant="light" color={status === 'approved' ? 'teal' : 'yellow'} mt={2}>
{status === 'approved' ? 'Ready to Issue' : 'Pending Approval'}
</Badge>
</div>
</Group>
<Text fz="xs" c="dimmed" mb="xs">{desc}</Text>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Validity</Text>
<Text fz="xs" fw={500}>{validity}</Text>
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</Tabs.Panel>
</Tabs>
{/* Note modal */}
<Modal
opened={noteModalOpen}
onClose={() => setNoteModalOpen(false)}
title={<Group gap="xs"><IconBook2 size={18} /><Text fw={700}>{noteAction === 'correction' ? 'Request Correction' : 'Reject Application'}</Text></Group>}
size="md" radius="lg"
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{noteAction === 'correction' ? 'Describe what the applicant needs to correct or resubmit.' : 'State the reason for rejection. The applicant will be notified.'}
</Text>
<Textarea label="Note to Applicant" placeholder="Enter your note..." rows={4} value={note} onChange={(e) => setNote(e.currentTarget.value)} required />
<Group justify="flex-end">
<Button variant="default" onClick={() => setNoteModalOpen(false)}>Cancel</Button>
<Button color={noteAction === 'correction' ? 'orange' : 'red'} disabled={!note.trim()} loading={actionLoading === noteAction}
onClick={() => performAction(noteAction!, noteAction === 'correction' ? 'sent back for correction' : 'rejected', noteAction === 'correction' ? 'correction_required' : 'rejected')}>
{noteAction === 'correction' ? 'Send Back' : 'Reject'}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,45 @@
import { baseApi } from '@ema-platform/api';
import type {
Certification,
ListResponse,
CreateCertificationPayload,
UpdateCertificationPayload,
} from '../types/certification';
const certificationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getCertifications: builder.query<ListResponse<Certification>, void>({
query: () => '/certifications',
providesTags: ['Api'],
}),
getCertification: builder.query<Certification, string>({
query: (id) => `/certifications/${id}`,
providesTags: ['Api'],
}),
createCertification: builder.mutation<Certification, CreateCertificationPayload>({
query: (body) => ({ url: '/certifications', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateCertification: builder.mutation<Certification, UpdateCertificationPayload>({
query: ({ id, ...body }) => ({
url: `/certifications/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteCertification: builder.mutation<void, string>({
query: (id) => ({ url: `/certifications/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetCertificationsQuery,
useGetCertificationQuery,
useCreateCertificationMutation,
useUpdateCertificationMutation,
useDeleteCertificationMutation,
} = certificationApi;

View File

@@ -0,0 +1,204 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Table,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
Textarea,
Paper,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
useUpdateCertificationMutation,
useDeleteCertificationMutation,
} from '../api/certification-api';
import type { Certification } from '../types/certification';
function CertificationForm({
editing,
isSubmitting,
onSubmit,
onCancel,
}: {
editing: Certification | null;
isSubmitting: boolean;
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [nameEn, setNameEn] = useState(editing?.name?.en ?? '');
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
const [descAm, setDescAm] = useState(editing?.description?.am ?? '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!nameEn || !nameAm) {
notify.error('Name fields are required');
return;
}
onSubmit({ nameEn, nameAm, descEn, descAm }, !!editing);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput label={t('certification.form.nameEn')} placeholder={t('certification.form.nameEnPlaceholder')} value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
export function CertificationPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data, isLoading, isError } = useGetCertificationsQuery();
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
const [deleteCert] = useDeleteCertificationMutation();
const certifications = data?.items ?? [];
const [editing, setEditing] = useState<Certification | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Certification | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const resetForm = () => {
setEditing(null);
setShowForm(false);
};
const handleSubmit = async (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => {
const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm };
try {
if (isEdit && editing) {
await updateCert({ id: editing.id, name, description }).unwrap();
notify.success(t('certification.updated'));
} else {
await createCert({ name, description }).unwrap();
notify.success(t('certification.created'));
}
resetForm();
} catch {
notify.error(t('certification.error'));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteCert(deleteTarget.id).unwrap();
notify.success(t('certification.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('certification.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('certification.title')}</Title>
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')}
</Button>
)}
</Group>
{showForm && (
<CertificationForm
editing={editing}
isSubmitting={isCreating || isUpdating}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
)}
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('certification.columns.name')}</Table.Th>
<Table.Th>{t('certification.columns.description')}</Table.Th>
<Table.Th>{t('certification.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{certifications.map((cert) => (
<Table.Tr key={cert.id}>
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
<Table.Td>
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{certifications.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
</Group>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,30 @@
export interface LocalePair {
en: string;
am: string;
}
export interface Certification {
id: string;
name: LocalePair;
description: LocalePair;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateCertificationPayload {
name: LocalePair;
description: LocalePair;
}
export interface UpdateCertificationPayload {
id: string;
name?: LocalePair;
description?: LocalePair;
isActive?: boolean;
}

View File

@@ -0,0 +1,338 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconEye,
IconFileDescription,
IconSearch,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
export type CoCAppStatus =
| 'Submitted'
| 'Document Review'
| 'TRB Inspection'
| 'TRB Resubmit Required'
| 'TRB Rejected'
| 'Examination Scheduled'
| 'Examination Passed'
| 'Examination Failed'
| 'Certificate Issued';
export interface CoCApp {
id: string;
seafarerId: string;
name: string;
email: string;
mobile: string;
nationality: string;
certType: string;
stcwRef: string;
level: 'Support' | 'Operational' | 'Management';
dept: string;
submitted: string;
paymentStatus: 'Verified' | 'Pending';
status: CoCAppStatus;
examDate: string | null;
examVenue: string | null;
seaServiceFile: string;
trbFile: string;
competencyDocs: { area: string; uploaded: boolean; filename: string }[];
trbInspectionNotes: string;
examNotes: string;
officerRemarks: string;
}
export const STATUS_COLOR: Record<CoCAppStatus, string> = {
'Submitted': 'gray',
'Document Review': 'blue',
'TRB Inspection': 'yellow',
'TRB Resubmit Required': 'orange',
'TRB Rejected': 'red',
'Examination Scheduled': 'indigo',
'Examination Passed': 'teal',
'Examination Failed': 'red',
'Certificate Issued': 'teal',
};
export const MOCK_COC_APPS: CoCApp[] = [
{
id: 'COC-APP-2025-001',
seafarerId: 'SF-2024-0001',
name: 'Abebe Girma',
email: 'abebe.g@email.com',
mobile: '+251 911 234 567',
nationality: 'Ethiopian',
certType: 'Officer in Charge of a Navigational Watch (OICNW)',
stcwRef: 'Reg. II/1 — Code A-II/1',
level: 'Operational',
dept: 'Deck',
submitted: '2025-03-10',
paymentStatus: 'Verified',
status: 'TRB Inspection',
examDate: null,
examVenue: null,
seaServiceFile: 'abebe_sea_service.pdf',
trbFile: 'abebe_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Operational Level', uploaded: true, filename: 'abebe_nav.pdf' },
{ area: 'Cargo Handling & Stowage', uploaded: true, filename: 'abebe_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'abebe_ops.pdf' },
{ area: 'Radio Communication (GMDSS)', uploaded: true, filename: 'abebe_gmdss.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'abebe_brm.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-002',
seafarerId: 'SF-2024-0002',
name: 'Sara Tadesse',
email: 'sara.t@email.com',
mobile: '+251 922 345 678',
nationality: 'Ethiopian',
certType: 'Able Seafarer Deck (AB)',
stcwRef: 'Reg. II/5 — Code A-II/5',
level: 'Support',
dept: 'Deck',
submitted: '2025-03-18',
paymentStatus: 'Verified',
status: 'Document Review',
examDate: null,
examVenue: null,
seaServiceFile: 'sara_sea_service.pdf',
trbFile: '',
competencyDocs: [
{ area: 'Navigation', uploaded: true, filename: 'sara_nav.pdf' },
{ area: 'Cargo Operations', uploaded: true, filename: 'sara_cargo.pdf' },
{ area: 'Ship Operations', uploaded: false, filename: '' },
{ area: 'Safety & Emergency', uploaded: true, filename: 'sara_safety.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: 'Missing Ship Operations certificate. Requested resubmission.',
},
{
id: 'COC-APP-2025-003',
seafarerId: 'SF-2024-0003',
name: 'Dawit Bekele',
email: 'dawit.b@email.com',
mobile: '+251 933 456 789',
nationality: 'Ethiopian',
certType: 'Officer in Charge of an Engineering Watch (OICEW)',
stcwRef: 'Reg. III/1 — Code A-III/1',
level: 'Operational',
dept: 'Engine',
submitted: '2025-03-25',
paymentStatus: 'Verified',
status: 'Examination Scheduled',
examDate: '2025-05-10',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'dawit_sea_service.pdf',
trbFile: 'dawit_trb.pdf',
competencyDocs: [
{ area: 'Marine Engineering at Operational Level', uploaded: true, filename: 'dawit_eng.pdf' },
{ area: 'Electrical & Control Systems', uploaded: true, filename: 'dawit_elec.pdf' },
{ area: 'Maintenance & Repair', uploaded: true, filename: 'dawit_maint.pdf' },
{ area: 'Controlling Ship Operations', uploaded: true, filename: 'dawit_ops.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'dawit_brm.pdf' },
],
trbInspectionNotes: 'TRB physically inspected on 2025-04-20. All competency entries signed off by approved assessor.',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-004',
seafarerId: 'SF-2023-0088',
name: 'Tekle Haile',
email: 'tekle.h@email.com',
mobile: '+251 944 567 890',
nationality: 'Ethiopian',
certType: 'Chief Mate — Ships 5003,000 GT',
stcwRef: 'Reg. II/2 — Code A-II/2',
level: 'Management',
dept: 'Deck',
submitted: '2025-04-01',
paymentStatus: 'Verified',
status: 'Examination Passed',
examDate: '2025-05-20',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'tekle_sea_service.pdf',
trbFile: 'tekle_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
],
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
officerRemarks: '',
},
];
const STATUS_OPTIONS = [
'All', 'Submitted', 'Document Review', 'TRB Inspection',
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
'Examination Passed', 'Examination Failed', 'Certificate Issued',
];
export function CoCQueuePage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const filtered = MOCK_COC_APPS.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: MOCK_COC_APPS.length,
trbPending: MOCK_COC_APPS.filter((a) => a.status === 'TRB Inspection' || a.status === 'TRB Resubmit Required').length,
examSched: MOCK_COC_APPS.filter((a) => a.status === 'Examination Scheduled').length,
issued: MOCK_COC_APPS.filter((a) => a.status === 'Certificate Issued' || a.status === 'Examination Passed').length,
};
return (
<Stack gap="md">
<div>
<Title order={3}>CoC / CoP Application Queue</Title>
<Text fz="sm" c="dimmed">Review documents, inspect TRBs, schedule examinations and issue certificates</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconShieldCheck },
{ label: 'TRB Inspection', value: stats.trbPending, color: 'yellow', icon: IconBook2 },
{ label: 'Exam Scheduled', value: stats.examSched, color: 'indigo', icon: IconClock },
{ label: 'Passed / Issued', value: stats.issued, color: 'teal', icon: IconCircleCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Application Queue</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, App ID or Seafarer ID…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(280) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? 'All')}
size="sm"
style={{ width: rem(200) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconShieldCheck size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No applications found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Seafarer', 'Certificate', 'Level', 'Docs', 'TRB', 'Payment', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((app) => {
const allDocs = app.competencyDocs.every((d) => d.uploaded);
return (
<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}>{app.name}</Text>
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs" maw={160} lh={1.3}>{app.certType}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.level === 'Management' ? 'violet' : app.level === 'Operational' ? 'blue' : 'gray'}>
{app.level}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={allDocs ? 'teal' : 'orange'}>
{app.competencyDocs.filter(d => d.uploaded).length}/{app.competencyDocs.length}
</Badge>
</Table.Td>
<Table.Td>
{app.trbFile
? <Badge size="xs" variant="light" color="teal"><IconCheck size={10} /> Submitted</Badge>
: <Badge size="xs" variant="light" color="gray">N/A</Badge>}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
{app.paymentStatus}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/coc-queue/${app.id}`)}>
Review
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_COC_APPS.length} applications</Text>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,621 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconBook2,
IconCalendar,
IconCertificate,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconExternalLink,
IconEye,
IconFileDescription,
IconInfoCircle,
IconShieldCheck,
IconUser,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_COC_APPS, STATUS_COLOR } from './CoCQueuePage';
import type { CoCApp, CoCAppStatus } from './CoCQueuePage';
// ---------------------------------------------------------------------------
// Demo doc viewer (same pattern as ApplicationReviewPage)
// ---------------------------------------------------------------------------
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
if (!fileName) return '';
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
const [open, setOpen] = useState(false);
const isPdf = fileName.endsWith('.pdf');
const url = getDocUrl(fileName);
if (!fileName) return <Badge size="xs" color="red" variant="light">Not uploaded</Badge>;
return (
<>
<Card withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" mb="sm">
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
<IconFileDescription size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600}>{label}</Text>
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
</div>
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
</Group>
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Box>
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View Full Document
</Button>
</Card>
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Modal>
</>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function SectionLabel({ children }: { children: string }) {
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
}
// ---------------------------------------------------------------------------
// Workflow progress bar
// ---------------------------------------------------------------------------
const STEP_ORDER: CoCAppStatus[] = [
'Submitted', 'Document Review', 'TRB Inspection',
'Examination Scheduled', 'Examination Passed', 'Certificate Issued',
];
function WorkflowBar({ status }: { status: CoCAppStatus }) {
const activeIdx = STEP_ORDER.indexOf(status);
const isBranch = ['TRB Rejected', 'TRB Resubmit Required', 'Examination Failed'].includes(status);
const labels = ['Submitted', 'Doc Review', 'TRB Inspect', 'Exam Scheduled', 'Exam Passed', 'Issued'];
return (
<Paper withBorder radius="md" p="md" mb="md">
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Application Progress</Text>
<Group gap={0} align="flex-start" wrap="nowrap">
{STEP_ORDER.map((s, i) => {
const done = isBranch ? false : activeIdx > i;
const current = !isBranch && activeIdx === i;
return (
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
<Stack gap={2} align="center" style={{ minWidth: rem(52) }}>
<Box style={{
width: rem(30), height: rem(30), borderRadius: '50%',
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{done
? <IconCheck size={14} color="white" stroke={2.5} />
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
</Box>
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{labels[i]}</Text>
</Stack>
{i < STEP_ORDER.length - 1 && (
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
)}
</Group>
);
})}
</Group>
{isBranch && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
<Text fz="xs" fw={600}>
{status === 'TRB Resubmit Required' && 'TRB resubmission required — applicant notified.'}
{status === 'TRB Rejected' && 'TRB rejected — applicant must submit a new application.'}
{status === 'Examination Failed' && 'Examination failed — applicant must submit a new application.'}
</Text>
</Alert>
)}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Action modal
// ---------------------------------------------------------------------------
const EXAM_VENUES = [
{ value: 'addis', label: 'EMA HQ — Addis Ababa' },
{ value: 'regional', label: 'EMA Regional Office — Djibouti Liaison' },
];
type ActionKey = 'advance_trb' | 'request_docs' | 'approve_trb' | 'resubmit_trb' | 'reject_trb' | 'exam_pass' | 'exam_fail' | 'issue_cert';
const ACTION_TO_STATUS: Record<ActionKey, CoCAppStatus> = {
advance_trb: 'TRB Inspection',
request_docs: 'Document Review',
approve_trb: 'Examination Scheduled',
resubmit_trb: 'TRB Resubmit Required',
reject_trb: 'TRB Rejected',
exam_pass: 'Examination Passed',
exam_fail: 'Examination Failed',
issue_cert: 'Certificate Issued',
};
function getActions(status: CoCAppStatus): { value: ActionKey; label: string }[] {
switch (status) {
case 'Submitted':
case 'Document Review':
return [
{ value: 'advance_trb', label: 'Documents OK — Move to TRB Inspection' },
{ value: 'request_docs', label: 'Request Additional Documents' },
];
case 'TRB Inspection':
case 'TRB Resubmit Required':
return [
{ value: 'approve_trb', label: 'TRB Approved — Schedule Examination' },
{ value: 'resubmit_trb', label: 'Request TRB Resubmission' },
{ value: 'reject_trb', label: 'Reject TRB (Close Application)' },
];
case 'Examination Scheduled':
return [
{ value: 'exam_pass', label: 'Record Result — Passed' },
{ value: 'exam_fail', label: 'Record Result — Failed' },
];
case 'Examination Passed':
return [{ value: 'issue_cert', label: 'Issue Certificate' }];
default:
return [];
}
}
function ActionModal({
app,
opened,
onClose,
onAction,
}: {
app: CoCApp;
opened: boolean;
onClose: () => void;
onAction: (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => void;
}) {
const [action, setAction] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [examDate, setExamDate] = useState('');
const [venue, setVenue] = useState<string | null>(null);
const actionOptions = getActions(app.status);
const needsExam = action === 'approve_trb';
const isDestructive = action === 'reject_trb' || action === 'exam_fail';
const handleConfirm = () => {
if (!action) return;
if (needsExam && (!examDate || !venue)) {
notify.error('Please set an examination date and venue.'); return;
}
const newStatus = ACTION_TO_STATUS[action as ActionKey];
const venueLabel = EXAM_VENUES.find(v => v.value === venue)?.label ?? venue ?? undefined;
onAction(newStatus, notes, examDate || undefined, venueLabel);
setAction(null); setNotes(''); setExamDate(''); setVenue(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconShieldCheck size={17} /><Text fw={700}>Officer Action</Text></Group>}
size="md"
radius="lg"
>
<Stack gap="md">
{actionOptions.length === 0 ? (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
<Text fz="sm" fw={600}>No further actions available for this status.</Text>
</Alert>
) : (
<>
<Select
label="Action"
placeholder="Select an action…"
data={actionOptions}
value={action}
onChange={setAction}
size="sm"
/>
{needsExam && (
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Examination Date" type="date" value={examDate} onChange={(e) => setExamDate(e.currentTarget.value)} leftSection={<IconCalendar size={14} />} size="sm" required />
<Select label="Venue" placeholder="Select venue" data={EXAM_VENUES} value={venue} onChange={setVenue} size="sm" required />
</SimpleGrid>
)}
<Textarea
label="Notes / Remarks (sent to applicant)"
placeholder="TRB inspection findings, exam result details, rejection reason…"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
rows={3}
size="sm"
/>
{isDestructive && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="xs" fw={600}>
{action === 'reject_trb' && 'This will permanently close the application. The applicant must submit a new application.'}
{action === 'exam_fail' && 'This will record an examination failure. The applicant must apply again to re-attempt.'}
</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
leftSection={<IconCheck size={14} />}
disabled={!action}
color={isDestructive ? 'red' : action === 'issue_cert' || action === 'approve_trb' || action === 'exam_pass' ? 'teal' : 'blue'}
onClick={handleConfirm}
>
Confirm
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main review page
// ---------------------------------------------------------------------------
export function CoCReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const original = MOCK_COC_APPS.find((a) => a.id === id);
const [app, setApp] = useState<CoCApp | null>(original ?? null);
const [actionModalOpen, setActionModalOpen] = useState(false);
if (!app) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/coc-queue')}>Back to Queue</Button>
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
</Stack>
);
}
const handleAction = (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => {
const trbStatuses: CoCAppStatus[] = ['TRB Inspection', 'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled'];
const examStatuses: CoCAppStatus[] = ['Examination Passed', 'Examination Failed'];
setApp((prev) => {
if (!prev) return prev;
return {
...prev,
status: newStatus,
examDate: examDate ?? prev.examDate,
examVenue: examVenue ?? prev.examVenue,
trbInspectionNotes: trbStatuses.includes(newStatus) ? notes || prev.trbInspectionNotes : prev.trbInspectionNotes,
examNotes: examStatuses.includes(newStatus) ? notes || prev.examNotes : prev.examNotes,
officerRemarks: notes || prev.officerRemarks,
};
});
const labels: Record<CoCAppStatus, string> = {
'Submitted': 'moved to Submitted',
'Document Review': 'moved to Document Review',
'TRB Inspection': 'moved to TRB Inspection',
'TRB Resubmit Required': 'TRB resubmission requested',
'TRB Rejected': 'TRB rejected — application closed',
'Examination Scheduled': `examination scheduled for ${examDate}`,
'Examination Passed': 'examination result recorded — Passed',
'Examination Failed': 'examination result recorded — Failed',
'Certificate Issued': 'certificate issued',
};
notify.success(`Application ${labels[newStatus] ?? newStatus}.`);
};
const allDocs = app.competencyDocs.every((d) => d.uploaded);
const actionOptions = getActions(app.status);
const isTerminal = ['TRB Rejected', 'Examination Failed', 'Certificate Issued'].includes(app.status);
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/coc-queue')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>CoC / CoP Application Review</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{app.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
</Group>
</div>
</Group>
<Badge size="lg" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
</Group>
{/* Action bar */}
{!isTerminal && actionOptions.length > 0 && (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Group gap="sm" justify="flex-end">
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
<Button size="sm" leftSection={<IconShieldCheck size={15} />} onClick={() => setActionModalOpen(true)}>
Take Action
</Button>
</Group>
</Paper>
)}
{isTerminal && (
<Alert
variant="light"
color={app.status === 'Certificate Issued' ? 'teal' : 'red'}
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
<Text fz="sm" fw={600}>
{app.status === 'Certificate Issued' && 'Certificate has been issued and dispatched to the seafarer.'}
{app.status === 'TRB Rejected' && 'TRB has been rejected. This application is closed. Applicant must re-apply.'}
{app.status === 'Examination Failed' && 'Examination failed. This application is closed. Applicant must re-apply.'}
</Text>
</Alert>
)}
<WorkflowBar status={app.status} />
<Tabs defaultValue="profile" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
<Tabs.Tab value="competency" leftSection={<IconFileDescription size={16} />}>Competency Docs</Tabs.Tab>
<Tabs.Tab value="trb" leftSection={<IconBook2 size={16} />}>TRB Inspection</Tabs.Tab>
<Tabs.Tab value="exam" leftSection={<IconCertificate size={16} />}>Examination</Tabs.Tab>
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
<Tabs.Tab value="issuance" leftSection={<IconShieldCheck size={16} />}>Issuance</Tabs.Tab>
</Tabs.List>
{/* ── Applicant ─────────────────────────────────────────── */}
<Tabs.Panel value="profile">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{app.name}</Text>
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
</div>
</Group>
<Divider mb="md" />
<SectionLabel>Personal Information</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="Email" value={app.email} />
<InfoRow label="Mobile" value={app.mobile} />
<InfoRow label="Nationality" value={app.nationality} />
</SimpleGrid>
<Divider mb="md" />
<SectionLabel>Certificate Applied For</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Certificate" value={app.certType} />
<InfoRow label="STCW Reference" value={app.stcwRef} />
<InfoRow label="Level" value={app.level} />
<InfoRow label="Department" value={app.dept} />
<InfoRow label="Submitted" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Competency Documents ──────────────────────────────── */}
<Tabs.Panel value="competency">
<Stack gap="md">
<Alert variant="light" color={allDocs ? 'teal' : 'orange'} icon={allDocs ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}>
{allDocs
? `All ${app.competencyDocs.length} competency certificates uploaded.`
: `${app.competencyDocs.filter(d => !d.uploaded).length} competency certificate(s) missing.`}
</Alert>
{/* Sea service */}
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Sea Service Record</SectionLabel>
<Box maw={360}>
<DocViewer fileName={app.seaServiceFile} label="Sea Service Record / Discharge Book" />
</Box>
</Paper>
{/* Competency certs */}
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Competency Certificates ({app.competencyDocs.length} areas)</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{app.competencyDocs.map((doc) => (
<div key={doc.area}>
<Group gap="xs" mb="xs">
{doc.uploaded
? <ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>
: <ThemeIcon size={18} radius="xl" color="red" variant="light"><IconX size={11} /></ThemeIcon>}
<Text fz="xs" fw={700}>{doc.area}</Text>
</Group>
<DocViewer fileName={doc.filename} label={doc.area} />
</div>
))}
</SimpleGrid>
</Paper>
</Stack>
</Tabs.Panel>
{/* ── TRB Inspection ────────────────────────────────────── */}
<Tabs.Panel value="trb">
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
The Training Record Book (TRB) must be physically inspected at the EMA office.
The applicant brings the original; record your inspection findings below after taking action.
</Alert>
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Submitted TRB</SectionLabel>
{app.trbFile
? <Box maw={360}><DocViewer fileName={app.trbFile} label="Training Record Book (TRB)" /></Box>
: <Badge color="gray" variant="light">Not submitted (entry-level certificate TRB not required)</Badge>}
</Paper>
{app.trbInspectionNotes && (
<Paper withBorder radius="lg" p="lg">
<SectionLabel>TRB Inspection Notes</SectionLabel>
<Text fz="sm">{app.trbInspectionNotes}</Text>
</Paper>
)}
{!app.trbInspectionNotes && app.trbFile && (
<Alert variant="light" color="blue" icon={<IconBook2 size={17} />}>
TRB inspection has not yet been recorded. Use "Take Action" above to approve, request resubmission, or reject the TRB.
</Alert>
)}
</Stack>
</Tabs.Panel>
{/* ── Examination ───────────────────────────────────────── */}
<Tabs.Panel value="exam">
<Stack gap="md">
{app.examDate ? (
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Scheduled Examination</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<InfoRow label="Examination Date" value={app.examDate} />
<InfoRow label="Venue" value={app.examVenue ?? ''} />
</SimpleGrid>
{app.examNotes && (
<>
<Divider mb="md" />
<SectionLabel>Examination Result Notes</SectionLabel>
<Text fz="sm">{app.examNotes}</Text>
</>
)}
{['Examination Scheduled'].includes(app.status) && (
<Alert variant="light" color="indigo" icon={<IconCalendar size={17} />} mt="md">
Examination is scheduled. After the examination, use "Take Action" to record the result (Pass or Fail).
</Alert>
)}
</Paper>
) : (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />}>
No examination scheduled yet. Examination is assigned by an EMA officer after TRB approval.
</Alert>
)}
</Stack>
</Tabs.Panel>
{/* ── Payment ───────────────────────────────────────────── */}
<Tabs.Panel value="payment">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Payment Status</Text>
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>
{app.paymentStatus}
</Badge>
</div>
</Group>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
<Text fw={700} fz="sm" mb="md">CoC / CoP Application Fee</Text>
{[
{ label: 'Application Processing Fee', amount: 800 },
{ label: 'Document Verification Fee', amount: 300 },
{ label: 'Examination Fee', amount: 500 },
{ label: 'Certificate Issuance Fee', amount: 400 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="sm" mb="xs" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total</Text>
<Text fz="md" fw={800} c="blue">ETB 2,000.00</Text>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
<InfoRow label="Transaction Reference" value="CBE-TXN-20250315-042" />
<InfoRow label="Payment Date" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Issuance ──────────────────────────────────────────── */}
<Tabs.Panel value="issuance">
<Stack gap="md">
<Alert
variant="light"
color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'}
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}
>
{app.status === 'Certificate Issued'
? 'Certificate has been issued. The seafarer has been notified.'
: 'Certificate will be issued after the application is fully approved and examination passed.'}
</Alert>
<Paper withBorder radius="lg" p="lg">
<SectionLabel>Certificate to be Issued</SectionLabel>
<Card withBorder radius="lg" p="md" style={{ borderColor: app.status === 'Certificate Issued' ? 'var(--mantine-color-teal-3)' : 'var(--mantine-color-gray-2)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="xl" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'gray'} radius="lg">
<IconShieldCheck size={22} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{app.certType}</Text>
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
<Badge size="xs" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'} mt={4}>
{app.status === 'Certificate Issued' ? 'Issued' : 'Pending'}
</Badge>
</div>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Validity" value="5 years" />
<InfoRow label="Revalidation" value="Before expiry — STCW Reg I/11" />
</SimpleGrid>
</Card>
</Paper>
</Stack>
</Tabs.Panel>
</Tabs>
<ActionModal
app={app}
opened={actionModalOpen}
onClose={() => setActionModalOpen(false)}
onAction={handleAction}
/>
</Stack>
);
}

View File

@@ -0,0 +1,47 @@
import { baseApi } from '@ema-platform/api';
import type {
Organization,
Profession,
ListResponse,
CreateProfessionPayload,
UpdateProfessionPayload,
} from '../types/configuration';
const configurationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getOrganizations: builder.query<ListResponse<Organization>, void>({
query: () => '/organizations',
providesTags: ['Api'],
}),
getProfessions: builder.query<ListResponse<Profession>, void>({
query: () => '/professions',
providesTags: ['Api'],
}),
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
query: (body) => ({ url: '/professions', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
query: ({ id, ...body }) => ({
url: `/professions/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteProfession: builder.mutation<void, string>({
query: (id) => ({ url: `/professions/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: true,
});
export const {
useGetOrganizationsQuery,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
} = configurationApi;

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback } from 'react';
import {
Stack,
Title,
Tabs,
Group,
Button,
TextInput,
Textarea,
Table,
ActionIcon,
Modal,
Text,
Select,
Paper,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { LocationPage } from '../../location/pages/LocationPage';
import { CertificationPage } from '../../certification/pages/CertificationPage';
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
} from '../api/configuration-api';
import type { Profession } from '../types/configuration';
interface ProfFormValues {
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
departmentId: string;
}
interface ProfFormProps {
editingProf: Profession | null;
deptOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: ProfFormValues, isEdit: boolean) => void;
onCancel: () => void;
}
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
const { t } = useTranslation();
const form = useForm<ProfFormValues>({
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
validate: {
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
},
});
useEffect(() => {
if (editingProf) {
form.setValues({
nameEn: editingProf.name.en,
nameAm: editingProf.name.am,
descEn: editingProf.description.en ?? '',
descAm: editingProf.description.am ?? '',
departmentId: editingProf.departmentId,
});
}
}, [editingProf]);
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t('configuration.nameEn')}
placeholder="English name"
{...form.getInputProps('nameEn')}
size="sm"
/>
<TextInput
label={t('configuration.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('nameAm')}
size="sm"
/>
<Textarea
label={t('configuration.descEn')}
placeholder="English description"
{...form.getInputProps('descEn')}
size="sm"
autosize
minRows={2}
/>
<Textarea
label={t('configuration.descAm')}
placeholder="የአማርኛ መግለጫ"
{...form.getInputProps('descAm')}
size="sm"
autosize
minRows={2}
/>
<Select
label={t('configuration.department')}
placeholder={t('configuration.selectDepartment')}
data={deptOptions}
{...form.getInputProps('departmentId')}
size="sm"
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">
{t('configuration.cancel')}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editingProf ? t('configuration.update') : t('configuration.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
function ProfessionTab() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: deptRes } = useGetOrganizationsQuery();
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
const [deleteProfession] = useDeleteProfessionMutation();
const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []);
const professions = profRes?.items ?? [];
const [editingProf, setEditingProf] = useState<Profession | null>(null);
const [showProfForm, setShowProfForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const deptOptions = departments.filter((d) => d?.status?.toLowerCase() === 'active').map((d) => ({
value: d.id,
label: d.name?.[locale] ?? d.name ?? '',
}));
const resetProfForm = useCallback(() => {
setEditingProf(null);
setShowProfForm(false);
}, []);
const handleEditProf = useCallback((prof: Profession) => {
setEditingProf(prof);
setShowProfForm(true);
}, []);
const handleDeleteProf = useCallback((prof: Profession) => {
setDeleteTarget(prof);
openDelete();
}, [openDelete]);
const confirmDeleteProf = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteProfession(deleteTarget.id).unwrap();
notify.success(t('configuration.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('configuration.error'));
}
}, [deleteTarget, deleteProfession, closeDelete, t]);
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm };
try {
if (editingProf) {
await updateProfession({
id: editingProf.id,
name,
description,
departmentId: values.departmentId,
}).unwrap();
notify.success(t('configuration.updated'));
} else {
await createProfession({
departmentId: values.departmentId,
name,
description,
}).unwrap();
notify.success(t('configuration.created'));
}
resetProfForm();
} catch {
notify.error(t('configuration.error'));
}
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
const getDeptName = useCallback((deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
}, [departments, locale]);
if (isLoading) {
return <Center py="xl"><Loader /></Center>;
}
if (isError) {
return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('configuration.error')} />;
}
return (
<>
<Group justify="space-between" align="flex-end" mb="md">
<Title order={2}>{t('configuration.professionsList')}</Title>
{!showProfForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowProfForm(true)}
size="sm"
>
{t('configuration.addProfession')}
</Button>
)}
</Group>
{showProfForm && (
<ProfessionForm
editingProf={editingProf}
deptOptions={deptOptions}
isSubmitting={isCreating || isUpdating}
onSubmit={handleProfSubmit}
onCancel={resetProfForm}
/>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.name')}</Table.Th>
<Table.Th>{t('configuration.description')}</Table.Th>
<Table.Th>{t('configuration.department')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{professions.filter((p) => p.isActive).map((prof) => (
<Table.Tr key={prof.id}>
<Table.Td>{prof.name[locale]}</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
</Table.Td>
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(prof)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(prof)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{professions.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noProfessions')}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
<Text mb="md">
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.[locale] ?? '' })}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
<Button color="red" onClick={confirmDeleteProf} size="sm">{t('configuration.delete')}</Button>
</Group>
</Modal>
</>
);
}
export function ConfigurationPage() {
const { t } = useTranslation();
return (
<Stack gap="lg">
<Title order={2}>{t('configuration.title')}</Title>
<Tabs defaultValue="professions">
<Tabs.List>
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
{t('configuration.professions')}
</Tabs.Tab>
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
{t('location.title')}
</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
{t('certification.title')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
<ProfessionTab />
</Tabs.Panel>
<Tabs.Panel value="locations" pt="md">
<LocationPage />
</Tabs.Panel>
<Tabs.Panel value="certifications" pt="md">
<CertificationPage />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -0,0 +1,47 @@
export interface NamePair {
en: string;
am: string;
}
export interface Organization {
id: string;
name: NamePair;
key: string;
isSuperAdmin: boolean;
isGovernmentOrganization: boolean;
status: string;
parentId: string | null;
organizationTypeId: string;
createdAt: string;
updatedAt: string;
}
export interface Profession {
id: string;
departmentId: string;
department?: Organization;
name: NamePair;
description: NamePair;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface CreateProfessionPayload {
departmentId: string;
name: NamePair;
description: NamePair;
}
export interface UpdateProfessionPayload {
id: string;
departmentId?: string;
name?: NamePair;
description?: NamePair;
isActive?: boolean;
}

View File

@@ -0,0 +1,318 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconCertificate,
IconCircleCheck,
IconClock,
IconEye,
IconSearch,
IconShieldCheck,
IconRubberStamp,
IconX,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// Endorsement = STCW Reg I/10 — flag state endorsement of a foreign-issued CoC.
// Workflow: Application Submitted → Document Verification → Endorsement Issued / Rejected
// ---------------------------------------------------------------------------
export type EndorsementStatus =
| 'Submitted'
| 'Document Verification'
| 'Pending Approval'
| 'Endorsed'
| 'Rejected';
export interface EndorsementApp {
id: string;
seafarerId: string;
name: string;
email: string;
mobile: string;
nationality: string;
submitted: string;
status: EndorsementStatus;
paymentStatus: 'Verified' | 'Pending';
// Foreign CoC details
foreignCocNumber: string;
foreignCocIssuer: string;
foreignCocCountry: string;
foreignCocIssueDate: string;
foreignCocExpiryDate: string;
foreignCocType: string;
stcwRef: string;
// Endorsement result
endorsementNumber: string;
endorsementIssueDate: string;
endorsementExpiryDate: string;
officerRemarks: string;
// Docs
foreignCocFile: string;
certifiedTranslationFile: string;
seamanBookFile: string;
medicalFile: string;
photoFile: string;
}
export const ENDORSEMENT_STATUS_COLOR: Record<EndorsementStatus, string> = {
'Submitted': 'gray',
'Document Verification': 'blue',
'Pending Approval': 'yellow',
'Endorsed': 'teal',
'Rejected': 'red',
};
export const MOCK_ENDORSEMENT_APPS: EndorsementApp[] = [
{
id: 'END-APP-2025-001',
seafarerId: 'SF-2024-0010',
name: 'Bereket Alemu',
email: 'bereket.a@email.com',
mobile: '+251 911 100 200',
nationality: 'Ethiopian',
submitted: '2025-04-05',
status: 'Document Verification',
paymentStatus: 'Verified',
foreignCocNumber: 'PHL-COC-2022-0045',
foreignCocIssuer: 'Maritime Industry Authority (MARINA)',
foreignCocCountry: 'Philippines',
foreignCocIssueDate: '2022-06-15',
foreignCocExpiryDate: '2027-06-15',
foreignCocType: 'Officer in Charge of a Navigational Watch',
stcwRef: 'STCW Reg. II/1',
endorsementNumber: '',
endorsementIssueDate: '',
endorsementExpiryDate: '',
officerRemarks: '',
foreignCocFile: 'bereket_foreign_coc.pdf',
certifiedTranslationFile: '',
seamanBookFile: 'bereket_seaman_book.pdf',
medicalFile: 'bereket_medical.pdf',
photoFile: 'bereket_photo.jpg',
},
{
id: 'END-APP-2025-002',
seafarerId: 'SF-2024-0011',
name: 'Hiwot Girma',
email: 'hiwot.g@email.com',
mobile: '+251 922 200 300',
nationality: 'Ethiopian',
submitted: '2025-04-10',
status: 'Pending Approval',
paymentStatus: 'Verified',
foreignCocNumber: 'GRC-COC-2021-0088',
foreignCocIssuer: 'Hellenic Coast Guard',
foreignCocCountry: 'Greece',
foreignCocIssueDate: '2021-09-20',
foreignCocExpiryDate: '2026-09-20',
foreignCocType: 'Officer in Charge of an Engineering Watch',
stcwRef: 'STCW Reg. III/1',
endorsementNumber: '',
endorsementIssueDate: '',
endorsementExpiryDate: '',
officerRemarks: 'All documents verified. Awaiting senior officer approval for endorsement issuance.',
foreignCocFile: 'hiwot_foreign_coc.pdf',
certifiedTranslationFile: 'hiwot_translation.pdf',
seamanBookFile: 'hiwot_seaman_book.pdf',
medicalFile: 'hiwot_medical.pdf',
photoFile: 'hiwot_photo.jpg',
},
{
id: 'END-APP-2025-003',
seafarerId: 'SF-2023-0055',
name: 'Mulat Bekele',
email: 'mulat.b@email.com',
mobile: '+251 933 300 400',
nationality: 'Ethiopian',
submitted: '2025-03-20',
status: 'Endorsed',
paymentStatus: 'Verified',
foreignCocNumber: 'KOR-COC-2020-0112',
foreignCocIssuer: 'Korea Maritime & Ocean University',
foreignCocCountry: 'South Korea',
foreignCocIssueDate: '2020-03-10',
foreignCocExpiryDate: '2025-03-10',
foreignCocType: 'Chief Mate',
stcwRef: 'STCW Reg. II/2',
endorsementNumber: 'EMA-END-2025-003',
endorsementIssueDate: '2025-04-01',
endorsementExpiryDate: '2025-03-10',
officerRemarks: 'Foreign CoC verified. Endorsement issued in accordance with STCW Reg I/10. Note: foreign CoC expires 2025-03-10; endorsement co-terminous.',
foreignCocFile: 'mulat_foreign_coc.pdf',
certifiedTranslationFile: '',
seamanBookFile: 'mulat_seaman_book.pdf',
medicalFile: 'mulat_medical.pdf',
photoFile: 'mulat_photo.jpg',
},
{
id: 'END-APP-2025-004',
seafarerId: 'SF-2024-0022',
name: 'Tigist Haile',
email: 'tigist.h@email.com',
mobile: '+251 944 400 500',
nationality: 'Ethiopian',
submitted: '2025-04-18',
status: 'Submitted',
paymentStatus: 'Pending',
foreignCocNumber: 'IND-COC-2023-0445',
foreignCocIssuer: 'Directorate General of Shipping',
foreignCocCountry: 'India',
foreignCocIssueDate: '2023-11-01',
foreignCocExpiryDate: '2028-11-01',
foreignCocType: 'Electro-Technical Officer (ETO)',
stcwRef: 'STCW Reg. III/6',
endorsementNumber: '',
endorsementIssueDate: '',
endorsementExpiryDate: '',
officerRemarks: '',
foreignCocFile: 'tigist_foreign_coc.pdf',
certifiedTranslationFile: '',
seamanBookFile: '',
medicalFile: '',
photoFile: '',
},
];
const STATUS_OPTIONS = ['All', 'Submitted', 'Document Verification', 'Pending Approval', 'Endorsed', 'Rejected'];
export function EndorsementQueuePage() {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const filtered = MOCK_ENDORSEMENT_APPS.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: MOCK_ENDORSEMENT_APPS.length,
pending: MOCK_ENDORSEMENT_APPS.filter((a) => ['Submitted', 'Document Verification', 'Pending Approval'].includes(a.status)).length,
endorsed: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Endorsed').length,
rejected: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Rejected').length,
};
return (
<Stack gap="md">
<div>
<Title order={3}>Endorsement Queue</Title>
<Text fz="sm" c="dimmed">Process flag-state endorsements of foreign-issued CoC certificates (STCW Reg I/10)</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconStamp },
{ label: 'Awaiting Review', value: stats.pending, color: 'yellow', icon: IconClock },
{ label: 'Endorsed', value: stats.endorsed, color: 'teal', icon: IconCircleCheck },
{ label: 'Rejected', value: stats.rejected, color: 'red', icon: IconShieldCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Endorsement Applications</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, App ID or Seafarer ID…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(280) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? 'All')}
size="sm"
style={{ width: rem(200) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconStamp size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No applications found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Seafarer', 'Foreign CoC Type', 'Issued By', 'Country', 'Expiry', 'Payment', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.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}>{app.name}</Text>
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocType}</Text></Table.Td>
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocIssuer}</Text></Table.Td>
<Table.Td><Text fz="xs">{app.foreignCocCountry}</Text></Table.Td>
<Table.Td><Text fz="xs">{app.foreignCocExpiryDate}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
{app.paymentStatus}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/endorsement-queue/${app.id}`)}>
Review
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_ENDORSEMENT_APPS.length} applications</Text>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,579 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconExternalLink,
IconEye,
IconFileDescription,
IconInfoCircle,
IconShieldCheck,
IconRubberStamp,
IconUser,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
MOCK_ENDORSEMENT_APPS,
ENDORSEMENT_STATUS_COLOR,
} from './EndorsementQueuePage';
import type { EndorsementApp, EndorsementStatus } from './EndorsementQueuePage';
// ---------------------------------------------------------------------------
// Doc viewer
// ---------------------------------------------------------------------------
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
function getDocUrl(fileName: string) {
if (!fileName) return '';
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
const [open, setOpen] = useState(false);
const isPdf = fileName.endsWith('.pdf');
const url = getDocUrl(fileName);
if (!fileName) {
return (
<Card withBorder radius="md" p="sm">
<Group gap="sm">
<ThemeIcon size="md" variant="light" color="red" radius="md"><IconFileDescription size={16} /></ThemeIcon>
<div>
<Text fz="sm" fw={600}>{label}</Text>
<Badge size="xs" color="red" variant="light">Not uploaded</Badge>
</div>
</Group>
</Card>
);
}
return (
<>
<Card withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" mb="sm">
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
<IconFileDescription size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600}>{label}</Text>
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
</div>
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
</Group>
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Box>
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View Full Document
</Button>
</Card>
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
{isPdf
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
</Modal>
</>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
function SectionLabel({ children }: { children: string }) {
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
}
// ---------------------------------------------------------------------------
// Workflow bar
// ---------------------------------------------------------------------------
const STEP_ORDER: EndorsementStatus[] = ['Submitted', 'Document Verification', 'Pending Approval', 'Endorsed'];
const STEP_LABELS = ['Submitted', 'Doc Verification', 'Pending Approval', 'Endorsed'];
function WorkflowBar({ status }: { status: EndorsementStatus }) {
const activeIdx = STEP_ORDER.indexOf(status);
const isRejected = status === 'Rejected';
return (
<Paper withBorder radius="md" p="md" mb="md">
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Endorsement Progress</Text>
<Group gap={0} align="flex-start" wrap="nowrap">
{STEP_ORDER.map((s, i) => {
const done = !isRejected && activeIdx > i;
const current = !isRejected && activeIdx === i;
return (
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
<Stack gap={2} align="center" style={{ minWidth: rem(60) }}>
<Box style={{
width: rem(30), height: rem(30), borderRadius: '50%',
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
{done
? <IconCheck size={14} color="white" stroke={2.5} />
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
</Box>
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{STEP_LABELS[i]}</Text>
</Stack>
{i < STEP_ORDER.length - 1 && (
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
)}
</Group>
);
})}
</Group>
{isRejected && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
<Text fz="xs" fw={600}>Application rejected. Applicant must submit a new application.</Text>
</Alert>
)}
</Paper>
);
}
// ---------------------------------------------------------------------------
// Action modal
// ---------------------------------------------------------------------------
type ActionKey = 'verify_docs' | 'request_docs' | 'pending_approval' | 'endorse' | 'reject';
function getActions(status: EndorsementStatus): { value: ActionKey; label: string }[] {
switch (status) {
case 'Submitted':
return [
{ value: 'verify_docs', label: 'Start Document Verification' },
{ value: 'request_docs', label: 'Request Missing Documents' },
];
case 'Document Verification':
return [
{ value: 'pending_approval', label: 'Documents Verified — Submit for Approval' },
{ value: 'request_docs', label: 'Request Additional Documents' },
{ value: 'reject', label: 'Reject Application' },
];
case 'Pending Approval':
return [
{ value: 'endorse', label: 'Issue Endorsement' },
{ value: 'reject', label: 'Reject Application' },
];
default:
return [];
}
}
const ACTION_TO_STATUS: Record<ActionKey, EndorsementStatus> = {
verify_docs: 'Document Verification',
request_docs: 'Document Verification',
pending_approval: 'Pending Approval',
endorse: 'Endorsed',
reject: 'Rejected',
};
function ActionModal({
app,
opened,
onClose,
onAction,
}: {
app: EndorsementApp;
opened: boolean;
onClose: () => void;
onAction: (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => void;
}) {
const [action, setAction] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
const [endNo, setEndNo] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const actionOptions = getActions(app.status);
const isEndorse = action === 'endorse';
const isDestructive = action === 'reject';
const handleConfirm = () => {
if (!action) return;
if (isEndorse && (!endNo || !issueDate || !expiryDate)) {
notify.error('Please fill in endorsement number, issue date and expiry date.'); return;
}
const newStatus = ACTION_TO_STATUS[action as ActionKey];
onAction(newStatus, remarks, endNo || undefined, issueDate || undefined, expiryDate || undefined);
setAction(null); setRemarks(''); setEndNo(''); setIssueDate(''); setExpiryDate('');
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconStamp size={17} /><Text fw={700}>Officer Action</Text></Group>}
size="md"
radius="lg"
>
<Stack gap="md">
{actionOptions.length === 0 ? (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
<Text fz="sm" fw={600}>No further actions available.</Text>
</Alert>
) : (
<>
<Select
label="Action"
placeholder="Select an action…"
data={actionOptions}
value={action}
onChange={setAction}
size="sm"
/>
{isEndorse && (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} p="xs">
<Text fz="xs">The endorsement is co-terminous with the foreign CoC. Expiry date must not exceed the foreign CoC expiry ({app.foreignCocExpiryDate}).</Text>
</Alert>
<TextInput
label="Endorsement Number"
placeholder="EMA-END-2025-XXX"
value={endNo}
onChange={(e) => setEndNo(e.currentTarget.value)}
size="sm"
required
/>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} size="sm" required />
</SimpleGrid>
</>
)}
<Textarea
label="Remarks / Notes (sent to applicant)"
placeholder="Verification findings, reason for rejection, or additional instructions…"
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
size="sm"
/>
{isDestructive && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="xs" fw={600}>This will permanently reject the application. The applicant must submit a new application.</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
leftSection={<IconCheck size={14} />}
disabled={!action}
color={isDestructive ? 'red' : isEndorse ? 'teal' : 'blue'}
onClick={handleConfirm}
>
Confirm
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main review page
// ---------------------------------------------------------------------------
export function EndorsementReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const original = MOCK_ENDORSEMENT_APPS.find((a) => a.id === id);
const [app, setApp] = useState<EndorsementApp | null>(original ?? null);
const [actionModalOpen, setActionModalOpen] = useState(false);
if (!app) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/endorsement-queue')}>Back to Queue</Button>
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
</Stack>
);
}
const handleAction = (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => {
setApp((prev) => prev ? {
...prev,
status: newStatus,
officerRemarks: remarks || prev.officerRemarks,
endorsementNumber: endNo ?? prev.endorsementNumber,
endorsementIssueDate: issueDate ?? prev.endorsementIssueDate,
endorsementExpiryDate: expiryDate ?? prev.endorsementExpiryDate,
} : prev);
const msgs: Record<EndorsementStatus, string> = {
'Submitted': 'moved to Submitted',
'Document Verification': 'moved to Document Verification',
'Pending Approval': 'documents verified — submitted for approval',
'Endorsed': `endorsement ${endNo} issued`,
'Rejected': 'application rejected',
};
notify.success(`Application ${msgs[newStatus]}.`);
};
const isTerminal = app.status === 'Endorsed' || app.status === 'Rejected';
const actionOptions = getActions(app.status);
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/endorsement-queue')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>Endorsement Application Review</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{app.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
</Group>
</div>
</Group>
<Badge size="lg" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
</Group>
{/* Action bar */}
{!isTerminal && actionOptions.length > 0 && (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Group gap="sm" justify="flex-end">
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
<Button size="sm" leftSection={<IconStamp size={15} />} onClick={() => setActionModalOpen(true)}>
Take Action
</Button>
</Group>
</Paper>
)}
{isTerminal && (
<Alert
variant="light"
color={app.status === 'Endorsed' ? 'teal' : 'red'}
icon={app.status === 'Endorsed' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
<Text fz="sm" fw={600}>
{app.status === 'Endorsed' && `Endorsement ${app.endorsementNumber} issued on ${app.endorsementIssueDate}.`}
{app.status === 'Rejected' && 'Application rejected. Applicant must submit a new application.'}
</Text>
</Alert>
)}
<WorkflowBar status={app.status} />
<Tabs defaultValue="applicant" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="applicant" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
<Tabs.Tab value="foreign-coc" leftSection={<IconShieldCheck size={16} />}>Foreign CoC</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFileDescription size={16} />}>Documents</Tabs.Tab>
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
<Tabs.Tab value="endorsement" leftSection={<IconStamp size={16} />}>Endorsement</Tabs.Tab>
</Tabs.List>
{/* ── Applicant ─────────────────────────────────────────── */}
<Tabs.Panel value="applicant">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{app.name}</Text>
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
</div>
</Group>
<Divider mb="md" />
<SectionLabel>Personal Information</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Email" value={app.email} />
<InfoRow label="Mobile" value={app.mobile} />
<InfoRow label="Nationality" value={app.nationality} />
<InfoRow label="Submitted" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Foreign CoC ───────────────────────────────────────── */}
<Tabs.Panel value="foreign-coc">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="blue" radius="lg"><IconShieldCheck size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Foreign Certificate of Competency</Text>
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
</div>
</Group>
<Divider mb="md" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="CoC Number" value={app.foreignCocNumber} />
<InfoRow label="Certificate Type" value={app.foreignCocType} />
<InfoRow label="Issuing Authority" value={app.foreignCocIssuer} />
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
<InfoRow label="Issue Date" value={app.foreignCocIssueDate} />
<InfoRow label="Expiry Date" value={app.foreignCocExpiryDate} />
<InfoRow label="STCW Reference" value={app.stcwRef} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="sm">
<Text fz="xs">
<strong>Verification checklist (STCW Reg I/10):</strong> Confirm the issuing state is an STCW party,
the certificate is authentic, valid, and not suspended or cancelled, and the seafarer holds the
required sea service and medical fitness.
</Text>
</Alert>
<Box maw={360} mt="md">
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC" />
</Box>
</Paper>
</Tabs.Panel>
{/* ── Supporting Documents ──────────────────────────────── */}
<Tabs.Panel value="documents">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Verify all supporting documents. A certified translation is required if the foreign CoC is not in English.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC (Original or Certified Copy)" />
<DocViewer fileName={app.certifiedTranslationFile} label="Certified Translation (if not English)" />
<DocViewer fileName={app.seamanBookFile} label="Ethiopian Seaman Book" />
<DocViewer fileName={app.medicalFile} label="Valid Medical Fitness Certificate (ENG I/2)" />
<DocViewer fileName={app.photoFile} label="Passport-Size Photo" />
</SimpleGrid>
</Stack>
</Tabs.Panel>
{/* ── Payment ───────────────────────────────────────────── */}
<Tabs.Panel value="payment">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Payment</Text>
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>{app.paymentStatus}</Badge>
</div>
</Group>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
<Text fw={700} fz="sm" mb="md">Endorsement Fee Breakdown</Text>
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="sm" mb="xs" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total</Text>
<Text fz="md" fw={800} c="blue">ETB 1,000.00</Text>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
<InfoRow label="Transaction Reference" value="CBE-TXN-20250405-088" />
<InfoRow label="Payment Date" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Endorsement ───────────────────────────────────────── */}
<Tabs.Panel value="endorsement">
<Stack gap="md">
{app.status === 'Endorsed' ? (
<>
<Alert variant="light" color="teal" icon={<IconCircleCheck size={17} />}>
Endorsement has been issued. The seafarer has been notified.
</Alert>
<Paper withBorder radius="lg" p="lg">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="teal" radius="lg"><IconStamp size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Issued Endorsement</Text>
<Text fz="xs" c="dimmed">STCW Reg I/10 Flag State Endorsement</Text>
</div>
</Group>
<Divider mb="md" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Endorsement Number" value={app.endorsementNumber} />
<InfoRow label="Issue Date" value={app.endorsementIssueDate} />
<InfoRow label="Expiry Date" value={app.endorsementExpiryDate} />
<InfoRow label="Certificate Type" value={app.foreignCocType} />
<InfoRow label="Original CoC No." value={app.foreignCocNumber} />
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
</SimpleGrid>
{app.officerRemarks && (
<>
<Divider mt="md" mb="md" />
<SectionLabel>Officer Remarks</SectionLabel>
<Text fz="sm">{app.officerRemarks}</Text>
</>
)}
</Paper>
</>
) : app.status === 'Rejected' ? (
<Paper withBorder radius="lg" p="lg">
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />} mb="md">
Application rejected. No endorsement was issued.
</Alert>
{app.officerRemarks && (
<>
<SectionLabel>Rejection Reason</SectionLabel>
<Text fz="sm">{app.officerRemarks}</Text>
</>
)}
</Paper>
) : (
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
Endorsement will be issued here once all documents are verified and the application is approved.
The endorsement is co-terminous with the foreign CoC (expires {app.foreignCocExpiryDate}).
</Alert>
)}
</Stack>
</Tabs.Panel>
</Tabs>
<ActionModal
app={app}
opened={actionModalOpen}
onClose={() => setActionModalOpen(false)}
onAction={handleAction}
/>
</Stack>
);
}

View File

@@ -0,0 +1,55 @@
import { baseApi } from '@ema-platform/api';
import type {
Exam,
ListResponse,
CreateExamPayload,
UpdateExamPayload,
AssignQuestionsPayload,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getExams: builder.query<ListResponse<Exam>, void>({
query: () => '/exams?q=i=questions',
providesTags: ['Api'],
}),
getExam: builder.query<Exam, string>({
query: (id) => `/exams/${id}?i=questions`,
providesTags: ['Api'],
}),
createExam: builder.mutation<Exam, CreateExamPayload>({
query: (body) => ({ url: '/exams', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateExam: builder.mutation<Exam, UpdateExamPayload>({
query: ({ id, ...body }) => ({
url: `/exams/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteExam: builder.mutation<void, string>({
query: (id) => ({ url: `/exams/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
assignQuestions: builder.mutation<Exam, AssignQuestionsPayload>({
query: ({ examId, questionIds, remark }) => ({
url: `/exams/${examId}/questions`,
method: 'POST',
body: { examId, questionIds, remark },
}),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetExamsQuery,
useGetExamQuery,
useCreateExamMutation,
useUpdateExamMutation,
useDeleteExamMutation,
useAssignQuestionsMutation,
} = examApi;

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import {
Paper,
Group,
Text,
Stack,
TextInput,
Badge,
ScrollArea,
Checkbox,
Box,
Button,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconSearch } from '@tabler/icons-react';
import type { QuestionBrief } from '../types/exam';
interface QuestionAssignerProps {
available: QuestionBrief[];
assigned: QuestionBrief[];
onChange: (assigned: QuestionBrief[]) => void;
mode?: 'manual' | 'random';
}
function QuestionList({
items,
selected,
onToggle,
search,
onSearchChange,
label,
}: {
items: QuestionBrief[];
selected: Set<string>;
onToggle: (id: string) => void;
search: string;
onSearchChange: (v: string) => void;
label: string;
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const placeholder = t('exam.assigner.search');
return (
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
<Paper withBorder radius="md">
<Group p="sm" pb={0}>
<TextInput
placeholder={placeholder}
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
size="xs"
style={{ flex: 1 }}
/>
</Group>
<ScrollArea h={280} p="sm" pt="xs">
<Stack gap={4}>
{items.length === 0 && (
<Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
)}
{items.map((q) => (
<Paper
key={q.id}
withBorder
p="xs"
radius="sm"
style={{
cursor: 'pointer',
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
}}
onClick={() => onToggle(q.id)}
>
<Group gap="sm" wrap="nowrap">
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" lineClamp={2}>{q.title[locale]}</Text>
<Group gap={4} mt={2}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
</Group>
</div>
</Group>
</Paper>
))}
</Stack>
</ScrollArea>
</Paper>
</Box>
);
}
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
const { t } = useTranslation();
const [searchLeft, setSearchLeft] = useState('');
const [searchRight, setSearchRight] = useState('');
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
const assignedIds = new Set(assigned.map((q) => q.id));
const filteredAvailable = available.filter(
(q) => !assignedIds.has(q.id) && (q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) || q.title.am.includes(searchLeft))
);
const filteredAssigned = assigned.filter(
(q) => q.title.en.toLowerCase().includes(searchRight.toLowerCase()) || q.title.am.includes(searchRight)
);
const assignSelected = () => {
const toAssign = available.filter((q) => selectedLeft.has(q.id));
onChange([...assigned, ...toAssign]);
setSelectedLeft(new Set());
};
const removeSelected = () => {
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
setSelectedRight(new Set());
};
return (
<Stack gap="sm">
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
<Group gap="sm" align="stretch" wrap="nowrap">
{mode === 'manual' && (
<QuestionList
items={filteredAvailable}
selected={selectedLeft}
onToggle={(id) => {
const next = new Set(selectedLeft);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedLeft(next);
}}
search={searchLeft}
onSearchChange={setSearchLeft}
label={t('exam.assigner.available')}
/>
)}
<QuestionList
items={filteredAssigned}
selected={selectedRight}
onToggle={(id) => {
const next = new Set(selectedRight);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedRight(next);
}}
search={searchRight}
onSearchChange={setSearchRight}
label={t('exam.assigner.assigned')}
/>
</Group>
{mode === 'manual' && (
<Group gap="sm" justify="center">
{selectedLeft.size > 0 && (
<Button size="xs" variant="light" onClick={assignSelected}>
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
</Button>
)}
{selectedRight.size > 0 && (
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
</Button>
)}
</Group>
)}
{mode === 'random' && selectedRight.size > 0 && (
<Group gap="sm" justify="center">
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
</Button>
</Group>
)}
</Stack>
);
}

View File

@@ -0,0 +1,367 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Stack,
Title,
Group,
Text,
Paper,
Badge,
SimpleGrid,
Divider,
Button,
ActionIcon,
Alert,
Loader,
Center,
Modal,
Table,
Select,
NumberInput,
TextInput,
ThemeIcon,
Box,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import {
IconArrowLeft,
IconPrinter,
IconPlus,
IconInfoCircle,
IconCertificate,
IconCalendar,
IconMapPin,
IconClock,
IconScoreboard,
IconUser,
IconCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { QuestionAssigner } from '../components/QuestionAssigner';
import { RecordResultModal } from '../../result/components/RecordResultModal';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
};
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
export function ExamDetailPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const printRef = useRef<HTMLDivElement>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
const [randomCount, setRandomCount] = useState(5);
const [updateExam] = useUpdateExamMutation();
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
const certifications = certRes?.items ?? [];
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
.filter((q) => q.certificationId === exam.certificationId && q.form === exam.form)
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allQuestions, exam?.certificationId, exam?.form]);
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError || !exam) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
</Stack>
);
}
const openAssignModal = () => {
setDraftQuestions(exam.questions ?? []);
setRandomCount(5);
openAssign();
};
const handleRandomSelect = () => {
const assignedIds = new Set(draftQuestions.map((q) => q.id));
const currentTotal = draftQuestions.reduce((s, q) => s + Number(q.points), 0);
const cuttingPoint = Number(exam.cuttingPoint);
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
if (eligible.length === 0) {
notify.error('No eligible questions available for random selection');
return;
}
const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
if (maxPossible < cuttingPoint) {
notify.error(`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`);
return;
}
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
const targetCount = Math.min(randomCount, shuffled.length);
const picked = shuffled.slice(0, targetCount);
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
if (currentTotal + pickedTotal < cuttingPoint) {
const remaining = shuffled.slice(targetCount);
for (const q of remaining) {
if (currentTotal + pickedTotal >= cuttingPoint) break;
picked.push(q);
pickedTotal += q.points;
}
}
const msg = picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
setDraftQuestions([...draftQuestions, ...picked]);
notify.info(msg);
};
const handleAssign = async () => {
try {
const questionIds = draftQuestions.map((q) => q.id);
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
} catch {
notify.error('Failed to assign questions');
}
};
const handlePrint = async () => {
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
if (total < Number(exam.cuttingPoint)) {
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) return;
let logoBase64 = '';
try {
const resp = await fetch('/ema-logo.png');
const blob = await resp.blob();
logoBase64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
} catch { /* logo not available */ }
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
const qHtml = (exam.questions ?? []).map((q, i) => {
const full = qMap.get(q.id);
const titleStr = q.title[locale] || q.title.en;
const descStr = full?.description?.[locale] || full?.description?.en || '';
return `
<div style="margin-bottom: 24px; page-break-inside: avoid;">
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ''}
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
</div>`;
}).join('');
printWindow.document.write(`
<html><head><title>${exam.title[locale] || exam.title.en}</title>
<style>
body { font-family: sans-serif; padding: 40px; max-width: 800px; margin: auto; }
.header { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #333; padding-bottom: 16px; }
.header-logo { max-width: 80px; margin-bottom: 8px; }
.header h1 { font-size: 20px; margin: 0 0 4px; }
.header p { margin: 2px 0; font-size: 13px; color: #555; }
.directions { background: #f5f5f5; padding: 12px 16px; border-radius: 4px; margin-bottom: 24px; font-size: 13px; }
.directions strong { display: block; margin-bottom: 4px; }
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
</style></head><body>
<div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
<h1>${exam.title[locale] || exam.title.en}</h1>
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
</div>
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
${qHtml}
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
Generated by EMA — Ethiopian Maritime Authority
</div>
</body></html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => printWindow.print(), 500);
};
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
return (
<Stack gap="md" ref={printRef}>
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>{exam.title[locale]}</Title>
</div>
</Group>
<Group gap="sm">
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
{t('exam.print')}
</Button>
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
{t('exam.recordResult')}
</Button>
</Group>
</Group>
{/* Status badge */}
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
{t(`exam.status.${exam.status}`)}
</Badge>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">{t('exam.detail.title')}</Title>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label={t('exam.detail.certification')} value={certName} />
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
<InfoRow label={t('exam.detail.date')} value={exam.date} />
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
</SimpleGrid>
{(exam.direction?.en || exam.direction?.am) && (
<>
<Divider my="md" />
<InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
</>
)}
</Paper>
{/* Questions */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
{t('exam.manageQuestions')}
</Button>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t('exam.noQuestionsAssigned')}
</Alert>
) : (
<Stack gap="md">
{(exam.questions ?? []).map((q, i) => (
<Paper key={q.id} withBorder p="md" radius="md">
<Group justify="space-between" mb="xs">
<Text fz="sm" fw={700}>{t('exam.detail.questionLabel')} {i + 1}</Text>
<Group gap={4}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
</Group>
</Group>
<Text fz="sm">{q.title[locale]}</Text>
</Paper>
))}
</Stack>
)}
</Paper>
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')}${exam.title[locale]}`} size="xl" radius="lg">
<Stack gap="md">
{exam.selectionMethod === 'MANUAL' ? (
<>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="manual"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
{t('exam.assigner.randomHint', { total: eligibleQuestions.length, pts: exam.cuttingPoint })}
</Text>
<Group gap="sm">
<NumberInput
placeholder={t('exam.assigner.selectCount')}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
{t('exam.randomSelect')}
</Button>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
</>
)}
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,309 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Stack,
Title,
Group,
Button,
Table,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
Textarea,
Paper,
Loader,
Center,
Alert,
Select,
NumberInput,
Tabs,
SimpleGrid,
Divider,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetExamsQuery,
useCreateExamMutation,
useUpdateExamMutation,
useDeleteExamMutation,
} from '../api/exam-api';
import type { Exam } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray',
ACTIVE: 'blue',
COMPLETED: 'teal',
CANCELLED: 'red',
POSTPONED: 'orange',
PUBLISHED: 'green',
};
function ExamForm({
editing,
certOptions,
isSubmitting,
onSubmit,
onCancel,
}: {
editing: Exam | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: any, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
const [date, setDate] = useState(editing?.date ?? '');
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
const [type, setType] = useState<string | null>(editing?.type ?? null);
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [venue, setVenue] = useState(editing?.venue ?? '');
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
notify.error('Please fill all required fields');
return;
}
onSubmit({
certificationId, titleEn, titleAm, directionEn, directionAm,
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
}, !!editing);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="basic">
<Stack gap="sm">
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
<Group gap="sm" grow>
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="settings">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<Select label={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
<Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
<Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
<Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
</SimpleGrid>
{editing && (
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
]} value={status} onChange={setStatus} size="sm" />
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
</Group>
</form>
</Paper>
);
}
export function ExamPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetExamsQuery();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
const [deleteExam] = useDeleteExamMutation();
const certifications = certRes?.items ?? [];
const exams = data?.items ?? [];
const [editing, setEditing] = useState<Exam | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const resetForm = () => { setEditing(null); setShowForm(false); };
const handleSubmit = async (values: any, isEdit: boolean) => {
const payload: any = {
certificationId: values.certificationId,
title: { en: values.titleEn, am: values.titleAm },
direction: values.directionEn || values.directionAm ? { en: values.directionEn, am: values.directionAm } : undefined,
date: values.date,
givenTime: { days: values.days, hours: values.hours, minutes: values.minutes },
type: values.type,
form: values.form,
venue: values.venue,
administrationMethod: values.adminMethod,
evaluationMethod: values.evalMethod,
selectionMethod: values.selMethod || 'MANUAL',
cuttingPoint: values.cuttingPoint,
};
if (isEdit) payload.status = values.status;
try {
if (isEdit && editing) {
await updateExam({ id: editing.id, ...payload }).unwrap();
notify.success(t('exam.updated'));
} else {
await createExam(payload).unwrap();
notify.success(t('exam.created'));
}
resetForm();
} catch {
notify.error(t('exam.error'));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteExam(deleteTarget.id).unwrap();
notify.success(t('exam.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('exam.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('exam.title')}</Title>
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('exam.add')}
</Button>
)}
</Group>
{showForm && (
<ExamForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
)}
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('exam.columns.title')}</Table.Th>
<Table.Th>{t('exam.columns.certification')}</Table.Th>
<Table.Th>{t('exam.columns.date')}</Table.Th>
<Table.Th>{t('exam.columns.type')}</Table.Th>
<Table.Th>{t('exam.columns.form')}</Table.Th>
<Table.Th>{t('exam.columns.venue')}</Table.Th>
<Table.Th>{t('exam.columns.questions')}</Table.Th>
<Table.Th>{t('exam.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{exams.map((exam) => (
<Table.Tr key={exam.id}>
<Table.Td>
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
{exam.title[locale]}
</Text>
</Table.Td>
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{t(`exam.type.${exam.type}`)}</Badge></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(exam); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(exam); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{exams.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text c="dimmed" ta="center" py="xl">{t('exam.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
{/* Delete confirmation */}
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
</Group>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,81 @@
import type { LocalePair } from '../../certification/types/certification';
import type { EstimatedTime } from '../../question/types/question';
import type { QuestionForm } from '../../question/types/question';
export type { QuestionForm };
export type ExamType = 'WRITTEN' | 'ORAL';
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
export interface QuestionBrief {
id: string;
title: LocalePair;
form: QuestionForm;
points: number;
}
export interface Exam {
id: string;
certificationId: string;
certification?: { id: string; name: LocalePair };
title: LocalePair;
direction: LocalePair | null;
date: string;
givenTime: EstimatedTime | null;
type: ExamType;
form: QuestionForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
selectionMethod: ExamSelectionMethod;
cuttingPoint: number;
status: ExamStatus;
questions?: QuestionBrief[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateExamPayload {
certificationId: string;
title: LocalePair;
direction?: LocalePair;
date: string;
givenTime: EstimatedTime;
type: ExamType;
form: QuestionForm;
venue: string;
administrationMethod: ExamAdministrationMethod;
evaluationMethod: ExamEvaluationMethod;
selectionMethod?: ExamSelectionMethod;
cuttingPoint: number;
}
export interface UpdateExamPayload {
id: string;
certificationId?: string;
title?: LocalePair;
direction?: LocalePair;
date?: string;
givenTime?: EstimatedTime;
type?: ExamType;
form?: QuestionForm;
venue?: string;
administrationMethod?: ExamAdministrationMethod;
evaluationMethod?: ExamEvaluationMethod;
selectionMethod?: ExamSelectionMethod;
cuttingPoint?: number;
status?: ExamStatus;
}
export interface AssignQuestionsPayload {
examId: string;
questionIds: string[];
remark?: LocalePair;
}

View File

@@ -35,7 +35,8 @@ export function LocationDetail({
onEdit,
onDelete,
}: LocationDetailProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const typeInfo = locationTypes.find(
(lt) => lt.id === location.locationTypeId,
);
@@ -47,7 +48,7 @@ export function LocationDetail({
return (
<Paper p="lg" radius="md" withBorder>
<Group justify="space-between" mb="xs">
<Title order={4}>{location.names.en}</Title>
<Title order={4}>{location.names[locale]}</Title>
<Group gap="xs">
<Button
variant="light"
@@ -89,7 +90,7 @@ export function LocationDetail({
</Badge>
{typeInfo && (
<Badge size="lg" variant="light" color="teal">
{typeInfo.names.en}
{typeInfo.names[locale]}
</Badge>
)}
</Group>
@@ -99,15 +100,9 @@ export function LocationDetail({
<Stack gap="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameEn')}
{t('location.name')}
</Text>
<Text size="sm">{location.names.en}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameAm')}
</Text>
<Text size="sm">{location.names.am}</Text>
<Text size="sm">{location.names[locale]}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
@@ -120,7 +115,7 @@ export function LocationDetail({
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.type')}
</Text>
<Text size="sm">{typeInfo.names.en} (Level {typeInfo.level})</Text>
<Text size="sm">{typeInfo.names[locale]} ({t('location.level')} {typeInfo.level})</Text>
</div>
)}
</Stack>

View File

@@ -44,7 +44,8 @@ export function LocationForm({
onCancel,
isSubmitting,
}: LocationFormProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const isEditing = !!editingLocation;
@@ -125,7 +126,7 @@ export function LocationForm({
{parentLocation && !isEditing && (
<TextInput
label={t('location.parent')}
value={parentLocation.names.en}
value={parentLocation.names[locale]}
disabled
mb="sm"
size="sm"
@@ -139,7 +140,7 @@ export function LocationForm({
placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({
value: lt.id,
label: lt.names.en,
label: lt.names[locale],
}))}
{...form.getInputProps('locationTypeId')}
size="sm"
@@ -153,7 +154,7 @@ export function LocationForm({
</Text>
{type ? (
<Badge size="lg" variant="light" color="blue">
{type.names.en}
{type.names[locale]}
</Badge>
) : (
<Text size="sm" c="red">

View File

@@ -32,7 +32,8 @@ interface LocationTypeFormValues {
}
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
const [createType] = useCreateLocationTypeMutation();
const [updateType] = useUpdateLocationTypeMutation();
@@ -49,9 +50,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
level: 1,
},
validate: {
code: (v) => (!v ? 'Code is required' : null),
namesEn: (v) => (!v ? 'English name is required' : null),
namesAm: (v) => (!v ? 'Amharic name is required' : null),
code: (v) => (!v ? t('location.validation.codeRequired') : null),
namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null),
namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null),
level: (v) => (v < 1 ? 'Level must be at least 1' : null),
},
});
@@ -119,6 +120,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
mt="md"
mb="md"
size="sm"
>
@@ -130,25 +132,25 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
<form onSubmit={handleSubmit}>
<Stack gap="sm" mb="md">
<TextInput
label="Code"
label={t('location.code')}
placeholder="e.g., COUNTRY, REGION, CITY"
{...form.getInputProps('code')}
size="sm"
/>
<TextInput
label="Name (English)"
label={t('location.nameEn')}
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
/>
<TextInput
label="Name (Amharic)"
label={t('location.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
/>
<NumberInput
label="Level"
label={t('location.level')}
placeholder="1"
min={1}
max={10}
@@ -179,10 +181,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Level</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Name (EN)</Table.Th>
<Table.Th>Name (AM)</Table.Th>
<Table.Th>{t('location.level')}</Table.Th>
<Table.Th>{t('location.code')}</Table.Th>
<Table.Th>{t('location.name')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -199,8 +200,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
{type.code}
</Badge>
</Table.Td>
<Table.Td>{type.names.en}</Table.Td>
<Table.Td>{type.names.am}</Table.Td>
<Table.Td>{type.names[locale]}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon

View File

@@ -31,7 +31,8 @@ import {
import type { Location } from '../types/location';
export function LocationPage() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
@@ -221,7 +222,7 @@ export function LocationPage() {
>
<Text mb="md">
{t('location.deleteConfirmText', {
name: selectedLocation?.names.en ?? '',
name: selectedLocation?.names[locale] ?? '',
})}
</Text>
<Group justify="flex-end">

View File

@@ -0,0 +1,381 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconAlertTriangle,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconEye,
IconHeart,
IconSearch,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data
// ---------------------------------------------------------------------------
interface MedicalRecord {
id: string;
seafarerId: string;
name: string;
email: string;
certNumber: string;
issuedBy: string;
issuedDate: string;
expiryDate: string;
daysLeft: number;
status: 'Pending Verification' | 'Verified' | 'Rejected' | 'Expiring' | 'Expired';
restrictions: string;
fileName: string;
submittedAt: string;
}
const MOCK_RECORDS: MedicalRecord[] = [
{ id: 'MV-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', certNumber: 'MC-2024-001', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-03-15', expiryDate: '2026-03-14', daysLeft: 45, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_abebe.pdf', submittedAt: '2024-03-16' },
{ id: 'MV-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', certNumber: 'MC-2024-002', issuedBy: 'Approved Medical Centre — Dire Dawa', issuedDate: '2024-05-01', expiryDate: '2026-04-30', daysLeft: 120, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_sara.pdf', submittedAt: '2024-05-02' },
{ id: 'MV-003', seafarerId: 'SF-2023-0088', name: 'Tekle Haile', email: 'tekle.h@email.com', certNumber: 'MC-2023-088', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-02-20', expiryDate: '2026-02-28', daysLeft: 31, status: 'Expiring', restrictions: 'Colour blind - deck duties restricted', fileName: 'medical_cert_tekle.pdf', submittedAt: '2024-02-21' },
{ id: 'MV-004', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', certNumber: 'MC-2024-003', issuedBy: 'Approved Medical Centre — Addis Ababa', issuedDate: '2024-04-10', expiryDate: '2026-04-09', daysLeft: 95, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_dawit.pdf', submittedAt: '2024-04-11' },
{ id: 'MV-005', seafarerId: 'SF-2022-0210', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', certNumber: 'MC-2022-210', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-01-05', expiryDate: '2026-01-30', daysLeft: 11, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_yonas.pdf', submittedAt: '2024-01-06' },
{ id: 'MV-006', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', certNumber: 'MC-2024-004', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-06-01', expiryDate: '2026-05-31', daysLeft: 365, status: 'Verified', restrictions: 'None', fileName: 'medical_cert_hana.pdf', submittedAt: '2024-06-02' },
];
const STATUS_COLOR: Record<string, string> = {
'Pending Verification': 'yellow',
Verified: 'teal',
Rejected: 'red',
Expiring: 'orange',
Expired: 'red',
};
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function VerificationDrawer({
record,
opened,
onClose,
onAction,
}: {
record: MedicalRecord | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'verify' | 'reject', remarks: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'verify' | 'reject' | null>(null);
if (!record) return null;
const submit = (action: 'verify' | 'reject') => {
onAction(record.id, action, remarks);
setRemarks('');
setConfirmModal(null);
onClose();
};
return (
<>
<Drawer
opened={opened}
onClose={onClose}
title={`Medical Certificate — ${record.name}`}
position="right"
size="lg"
padding="lg"
>
<Stack gap="md">
{/* Certificate details */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Certificate Details</Text>
<SimpleGrid cols={2} spacing="xs">
{[
['Seafarer ID', record.seafarerId],
['Certificate No.', record.certNumber],
['Issued By', record.issuedBy],
['Issue Date', formatDate(record.issuedDate)],
['Expiry Date', formatDate(record.expiryDate)],
['Days Remaining', `${record.daysLeft} days`],
['Restrictions', record.restrictions],
['Submitted', formatDate(record.submittedAt)],
].map(([label, value]) => (
<Box key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={label === 'Days Remaining' ? 700 : 400} c={label === 'Days Remaining' && record.daysLeft <= 30 ? 'red' : label === 'Days Remaining' && record.daysLeft <= 90 ? 'orange' : undefined}>
{value}
</Text>
</Box>
))}
</SimpleGrid>
</Paper>
{/* Status */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={700} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
</Group>
</Paper>
{/* Document preview */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={700} fz="sm">Certificate Document</Text>
<Button size="xs" variant="light" leftSection={<IconDownload size={13} />}>{record.fileName}</Button>
</Group>
<Box
style={{
height: rem(120),
borderRadius: rem(8),
border: '2px dashed var(--mantine-color-default-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--mantine-color-gray-0)',
}}
>
<Stack align="center" gap={4}>
<IconEye size={24} color="var(--mantine-color-gray-4)" />
<Text fz="xs" c="dimmed">Click download to view the certificate</Text>
</Stack>
</Box>
</Paper>
{/* Officer remarks */}
<Textarea
label="Verification Remarks"
placeholder="Add notes about validity, restrictions, or rejection reason…"
minRows={3}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
{/* Action buttons */}
{record.status === 'Pending Verification' && (
<Group grow>
<Button
color="teal"
leftSection={<IconCircleCheck size={15} />}
onClick={() => setConfirmModal('verify')}
>
Verify & Approve
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={15} />}
onClick={() => setConfirmModal('reject')}
>
Reject
</Button>
</Group>
)}
{record.status !== 'Pending Verification' && (
<Text fz="sm" c="dimmed" ta="center">This certificate has already been {record.status.toLowerCase()}.</Text>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={`Confirm ${confirmModal === 'verify' ? 'Verification' : 'Rejection'}`}
centered
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'verify'
? 'Are you sure you want to verify and approve this medical certificate? The seafarer will be notified.'
: 'Are you sure you want to reject this certificate? Please ensure you have added a reason in the remarks.'}
</Text>
{!remarks && confirmModal === 'reject' && (
<Text fz="xs" c="red" mb="sm">Please add rejection remarks before proceeding.</Text>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
color={confirmModal === 'verify' ? 'teal' : 'red'}
disabled={!remarks && confirmModal === 'reject'}
onClick={() => submit(confirmModal!)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function MedicalVerificationPage() {
const [records, setRecords] = useState<MedicalRecord[]>(MOCK_RECORDS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selected, setSelected] = useState<MedicalRecord | null>(null);
const stats = {
pending: records.filter((r) => r.status === 'Pending Verification').length,
expiring: records.filter((r) => r.status === 'Expiring').length,
verified: records.filter((r) => r.status === 'Verified').length,
critical: records.filter((r) => r.daysLeft <= 30).length,
};
const filtered = records.filter((r) => {
const q = search.toLowerCase();
const matchSearch = !q || r.name.toLowerCase().includes(q) || r.seafarerId.toLowerCase().includes(q) || r.certNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || r.status === statusFilter;
return matchSearch && matchStatus;
});
const handleAction = (id: string, action: 'verify' | 'reject', remarks: string) => {
setRecords((prev) => prev.map((r) =>
r.id === id ? { ...r, status: action === 'verify' ? 'Verified' : 'Rejected' } : r
));
notify.success(`Medical certificate ${action === 'verify' ? 'verified' : 'rejected'} successfully.`);
};
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>Medical Certificate Verification</Title>
<Text fz="sm" c="dimmed">Review and verify seafarer medical certificates (STCW valid 2 years)</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Pending Verification', value: stats.pending, color: 'yellow', icon: IconClock },
{ label: 'Expiring (90 days)', value: stats.expiring, color: 'orange', icon: IconAlertTriangle },
{ label: 'Critical (≤30 days)', value: stats.critical, color: 'red', icon: IconAlertCircle },
{ label: 'Verified', value: stats.verified, color: 'teal', icon: IconCircleCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Medical Certificate Records</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, ID or cert number…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(260) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
placeholder="All Status"
data={['Pending Verification', 'Verified', 'Rejected', 'Expiring', 'Expired']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(190) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconHeart size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No records found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer', 'Cert Number', 'Issued By', 'Issue Date', 'Expiry Date', 'Days Left', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((rec) => (
<Table.Tr key={rec.id}>
<Table.Td>
<Text fz="xs" fw={500}>{rec.name}</Text>
<Text fz="xs" c="dimmed">{rec.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs" fw={600}>{rec.certNumber}</Text></Table.Td>
<Table.Td><Text fz="xs" maw={160} truncate>{rec.issuedBy}</Text></Table.Td>
<Table.Td><Text fz="xs">{formatDate(rec.issuedDate)}</Text></Table.Td>
<Table.Td><Text fz="xs">{formatDate(rec.expiryDate)}</Text></Table.Td>
<Table.Td>
<Badge
color={rec.daysLeft <= 30 ? 'red' : rec.daysLeft <= 90 ? 'orange' : 'teal'}
variant="light"
size="xs"
>
{rec.daysLeft}d
</Badge>
</Table.Td>
<Table.Td><Badge color={STATUS_COLOR[rec.status] ?? 'gray'} variant="light" size="xs">{rec.status}</Badge></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => setSelected(rec)}>
{rec.status === 'Pending Verification' ? 'Verify' : 'View'}
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {records.length} records</Text>
<Group gap={4}>
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="dimmed">Data loaded</Text>
</Group>
</Group>
)}
</Paper>
<VerificationDrawer
record={selected}
opened={!!selected}
onClose={() => setSelected(null)}
onAction={handleAction}
/>
</Stack>
);
}

View File

@@ -0,0 +1,418 @@
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Switch,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconCheck,
IconCreditCard,
IconEdit,
IconId,
IconInfoCircle,
IconShieldCheck,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CertFee {
id: string;
certType: string;
icon: typeof IconBook2;
color: string;
description: string;
fees: { label: string; amount: number; editable: boolean }[];
enabled: boolean;
}
interface PaymentMethod {
id: string;
name: string;
shortName: string;
accountNumber: string;
accountName: string;
instructions: string;
enabled: boolean;
}
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
const INITIAL_CERT_FEES: CertFee[] = [
{
id: 'seaman-book',
certType: 'Seaman Book',
icon: IconBook2,
color: 'blue',
description: 'Official EMA seafarer identification book — valid 5 years',
fees: [
{ label: 'Application Fee', amount: 500, editable: true },
{ label: 'Document Verification Fee',amount: 200, editable: true },
],
enabled: true,
},
{
id: 'btc',
certType: 'Basic Training Certificate (BTC)',
icon: IconCertificate,
color: 'teal',
description: 'EMA-issued BTC certifying all 5 basic safety training courses — valid 5 years',
fees: [
{ label: 'Application Fee', amount: 300, editable: true },
{ label: 'Document Verification Fee',amount: 100, editable: true },
],
enabled: true,
},
{
id: 'bsid',
certType: 'BSID (Biometric Seafarer ID)',
icon: IconId,
color: 'violet',
description: 'Biometric Seafarer Identity Document — valid 5 years',
fees: [
{ label: 'Application Fee', amount: 100, editable: true },
{ label: 'Card Production Fee', amount: 150, editable: true },
],
enabled: true,
},
{
id: 'certificate',
certType: 'Certificate (CoC / CoP)',
icon: IconShieldCheck,
color: 'orange',
description: 'Certificate of Competency or Certificate of Proficiency under STCW',
fees: [
{ label: 'Application Fee', amount: 800, editable: true },
{ label: 'Examination Fee', amount: 400, editable: true },
{ label: 'Certificate Issuance Fee', amount: 200, editable: true },
],
enabled: true,
},
];
const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
{
id: 'cbe',
name: 'Commercial Bank of Ethiopia',
shortName: 'CBE',
accountNumber: '1000123456789',
accountName: 'EMA Maritime Authority',
instructions: 'Transfer the exact fee amount. Use your full name as the transfer description.',
enabled: true,
},
{
id: 'telebirr',
name: 'Telebirr (Ethio Telecom)',
shortName: 'Telebirr',
accountNumber: '+251 11 551 0000',
accountName: 'EMA Maritime Authority',
instructions: 'Send to Telebirr number. Screenshot your confirmation and upload it with your application.',
enabled: true,
},
];
// ---------------------------------------------------------------------------
// Fee edit modal
// ---------------------------------------------------------------------------
function FeeEditModal({
cert,
opened,
onClose,
onSave,
}: {
cert: CertFee | null;
opened: boolean;
onClose: () => void;
onSave: (id: string, fees: CertFee['fees']) => void;
}) {
const [fees, setFees] = useState<CertFee['fees']>(cert?.fees ?? []);
const handleOpen = () => setFees(cert?.fees ?? []);
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Fees {cert?.certType}</Text></Group>}
size="md"
radius="lg"
onTransitionEnd={() => { if (opened) handleOpen(); }}
>
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
Changes take effect on new applications immediately. Existing applications retain the fee at time of submission.
</Alert>
{fees.map((fee, i) => (
<Group key={fee.label} gap="sm" align="flex-end">
<TextInput label="Fee Label" value={fee.label} disabled style={{ flex: 1 }} size="sm" />
<NumberInput
label="Amount (ETB)"
value={fee.amount}
min={0}
step={50}
style={{ width: rem(140) }}
size="sm"
disabled={!fee.editable}
onChange={(v) =>
setFees((prev) => prev.map((f, idx) => idx === i ? { ...f, amount: Number(v) || 0 } : f))
}
/>
</Group>
))}
<Divider />
<Paper withBorder radius="md" p="sm" bg="gray.0">
<Group justify="space-between">
<Text fz="sm" fw={700}>Total</Text>
<Text fz="sm" fw={700} c="blue">ETB {fees.reduce((s, f) => s + f.amount, 0).toFixed(2)}</Text>
</Group>
</Paper>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(cert!.id, fees); onClose(); }}>
Save Fees
</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Payment method edit modal
// ---------------------------------------------------------------------------
function MethodEditModal({
method,
opened,
onClose,
onSave,
}: {
method: PaymentMethod | null;
opened: boolean;
onClose: () => void;
onSave: (updated: PaymentMethod) => void;
}) {
const [form, setForm] = useState<PaymentMethod>(method ?? INITIAL_PAYMENT_METHODS[0]);
const set = (k: keyof PaymentMethod, v: string) => setForm((p) => ({ ...p, [k]: v }));
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Payment Method {method?.shortName}</Text></Group>}
size="md"
radius="lg"
onTransitionEnd={() => { if (opened && method) setForm(method); }}
>
<Stack gap="md">
<TextInput label="Account Number / Phone" value={form.accountNumber} onChange={(e) => set('accountNumber', e.currentTarget.value)} />
<TextInput label="Account Name" value={form.accountName} onChange={(e) => set('accountName', e.currentTarget.value)} />
<TextInput label="Instructions (shown to applicant)" value={form.instructions} onChange={(e) => set('instructions', e.currentTarget.value)} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(form); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function PaymentConfigPage() {
const [certFees, setCertFees] = useState<CertFee[]>(INITIAL_CERT_FEES);
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
const [editingCert, setEditingCert] = useState<CertFee | null>(null);
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
const totalCombined = certFees
.filter((c) => c.enabled)
.flatMap((c) => c.fees)
.reduce((s, f) => s + f.amount, 0);
const handleSaveFees = (id: string, fees: CertFee['fees']) => {
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, fees } : c));
notify.success('Fees updated successfully.');
};
const handleToggleCert = (id: string) => {
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, enabled: !c.enabled } : c));
};
const handleSaveMethod = (updated: PaymentMethod) => {
setMethods((prev) => prev.map((m) => m.id === updated.id ? updated : m));
notify.success('Payment method updated.');
};
const handleToggleMethod = (id: string) => {
setMethods((prev) => prev.map((m) => m.id === id ? { ...m, enabled: !m.enabled } : m));
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>Payment Configuration</Title>
<Text fz="sm" c="dimmed">Manage certificate fees and accepted payment methods shown to applicants</Text>
</div>
<Badge size="lg" variant="light" color="blue">
Combined Total: ETB {totalCombined.toFixed(2)}
</Badge>
</Group>
{/* Fee summary table */}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="md">Certificate Fees</Text>
<Text fz="xs" c="dimmed">Click Edit to change fee amounts</Text>
</Group>
<Table highlightOnHover verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Certificate Type', 'Fee Breakdown', 'Total', '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>
{certFees.map((cert) => {
const CertIcon = cert.icon;
const total = cert.fees.reduce((s, f) => s + f.amount, 0);
return (
<Table.Tr key={cert.id} style={{ opacity: cert.enabled ? 1 : 0.5 }}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="md" variant="light" color={cert.color} radius="md">
<CertIcon size={16} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>{cert.certType}</Text>
<Text fz="xs" c="dimmed">{cert.description}</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={2}>
{cert.fees.map((f) => (
<Group key={f.label} gap={6}>
<Text fz="xs" c="dimmed">{f.label}:</Text>
<Text fz="xs" fw={500}>ETB {f.amount.toFixed(2)}</Text>
</Group>
))}
</Stack>
</Table.Td>
<Table.Td>
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
</Table.Td>
<Table.Td>
<Switch
checked={cert.enabled}
onChange={() => handleToggleCert(cert.id)}
size="sm"
color="teal"
label={cert.enabled ? 'Active' : 'Disabled'}
/>
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
<IconEdit size={15} />
</ActionIcon>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={2}><Text fz="sm" fw={700} ta="right">Grand Total (all active)</Text></Table.Td>
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {totalCombined.toFixed(2)}</Text></Table.Td>
<Table.Td colSpan={2} />
</Table.Tr>
</Table.Tfoot>
</Table>
</Paper>
{/* Payment methods */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} fz="md" mb="lg">Accepted Payment Methods</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{methods.map((method) => (
<Card key={method.id} withBorder radius="md" p="md" style={{ opacity: method.enabled ? 1 : 0.6 }}>
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size="lg" variant="light" color={method.enabled ? 'blue' : 'gray'} radius="md">
<IconCreditCard size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{method.name}</Text>
<Badge size="xs" variant="light" color={method.enabled ? 'teal' : 'gray'}>
{method.enabled ? 'Active' : 'Disabled'}
</Badge>
</div>
</Group>
<Group gap="xs">
<Switch checked={method.enabled} onChange={() => handleToggleMethod(method.id)} size="sm" color="teal" />
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingMethod(method)}>
<IconEdit size={15} />
</ActionIcon>
</Group>
</Group>
<Divider mb="sm" />
<Stack gap={4}>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Account / Number</Text>
<Text fz="xs" fw={600}>{method.accountNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Account Name</Text>
<Text fz="xs" fw={600}>{method.accountName}</Text>
</Group>
</Stack>
<Text fz="xs" c="dimmed" mt="sm" lh={1.4}>{method.instructions}</Text>
</Card>
))}
</SimpleGrid>
</Paper>
{/* Fee edit modal */}
<FeeEditModal
cert={editingCert}
opened={!!editingCert}
onClose={() => setEditingCert(null)}
onSave={handleSaveFees}
/>
{/* Method edit modal */}
<MethodEditModal
method={editingMethod}
opened={!!editingMethod}
onClose={() => setEditingMethod(null)}
onSave={handleSaveMethod}
/>
</Stack>
);
}

View File

@@ -26,6 +26,8 @@ import {
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconLayoutNavbar,
IconLayoutSidebar,
IconLock,
IconMail,
IconMoon,
@@ -46,6 +48,8 @@ import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setLayoutMode } from '../../../store/preferences.slice';
import type { LayoutMode } from '../../../store/preferences.slice';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -72,6 +76,7 @@ export function ProfilePage() {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -572,6 +577,48 @@ export function ProfilePage() {
<Divider />
<div>
<Title order={5}>{t('profile.layout.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.layout.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{([{ value: 'top', label: t('profile.layout.top'), icon: IconLayoutNavbar }, { value: 'sidebar', label: t('profile.layout.sidebar'), icon: IconLayoutSidebar }] as const).map(({ value, label, icon: Icon }) => {
const active = layoutMode === value;
return (
<UnstyledButton
key={value}
onClick={() => dispatch(setLayoutMode(value))}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-gray-6)" />

View File

@@ -0,0 +1,45 @@
import { baseApi } from '@ema-platform/api';
import type {
Question,
ListResponse,
CreateQuestionPayload,
UpdateQuestionPayload,
} from '../types/question';
const questionApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getQuestions: builder.query<ListResponse<Question>, void>({
query: () => '/questions',
providesTags: ['Api'],
}),
getQuestion: builder.query<Question, string>({
query: (id) => `/questions/${id}`,
providesTags: ['Api'],
}),
createQuestion: builder.mutation<Question, CreateQuestionPayload>({
query: (body) => ({ url: '/questions', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateQuestion: builder.mutation<Question, UpdateQuestionPayload>({
query: ({ id, ...body }) => ({
url: `/questions/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteQuestion: builder.mutation<void, string>({
query: (id) => ({ url: `/questions/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetQuestionsQuery,
useGetQuestionQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
} = questionApi;

View File

@@ -0,0 +1,242 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Table,
Badge,
ActionIcon,
Modal,
Text,
TextInput,
Paper,
Loader,
Center,
Alert,
Select,
NumberInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import {
useGetQuestionsQuery,
useCreateQuestionMutation,
useUpdateQuestionMutation,
useDeleteQuestionMutation,
} from '../api/question-api';
import type { Question, QuestionForm } from '../types/question';
function QuestionForm({
editing,
certOptions,
isSubmitting,
onSubmit,
onCancel,
}: {
editing: Question | null;
certOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: {
certificationId: string;
titleEn: string;
titleAm: string;
form: string;
points: number;
days: number;
hours: number;
minutes: number;
}, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0);
const [hours, setHours] = useState(editing?.time?.hours ?? 0);
const [minutes, setMinutes] = useState(editing?.time?.minutes ?? 0);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!certificationId || !titleEn || !titleAm || !form) {
notify.error('Please fill all required fields');
return;
}
onSubmit({
certificationId, titleEn, titleAm, form, points, days, hours, minutes
}, !!editing);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</Text>
<Group gap="sm" grow>
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
export function QuestionPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetQuestionsQuery();
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
const [deleteQ] = useDeleteQuestionMutation();
const certifications = certRes?.items ?? [];
const questions = data?.items ?? [];
const [editing, setEditing] = useState<Question | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Question | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const resetForm = () => { setEditing(null); setShowForm(false); };
const handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string;
form: string; points: number; days: number; hours: number; minutes: number;
}, isEdit: boolean) => {
const title = { en: values.titleEn, am: values.titleAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.updated'));
} else {
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.created'));
}
resetForm();
} catch {
notify.error(t('question.error'));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteQ(deleteTarget.id).unwrap();
notify.success(t('question.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('question.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<Title order={2}>{t('question.title')}</Title>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
</Button>
)}
</Group>
{showForm && (
<QuestionForm
editing={editing}
certOptions={certOptions}
isSubmitting={isCreating || isUpdating}
onSubmit={handleSubmit}
onCancel={resetForm}
/>
)}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('question.pool')}</Text>
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('question.columns.title')}</Table.Th>
<Table.Th>{t('question.columns.certification')}</Table.Th>
<Table.Th>{t('question.columns.form')}</Table.Th>
<Table.Th>{t('question.columns.points')}</Table.Th>
<Table.Th>{t('question.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.deleteConfirmText')}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
</Group>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,48 @@
import type { LocalePair } from '../../certification/types/certification';
export type QuestionForm = 'ESSAY' | 'CHOICE';
export interface EstimatedTime {
days: number;
hours: number;
minutes: number;
}
export interface Question {
id: string;
certificationId: string;
certification?: { id: string; name: LocalePair };
title: LocalePair;
description: LocalePair;
form: QuestionForm;
time: EstimatedTime | null;
points: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateQuestionPayload {
certificationId: string;
title: LocalePair;
description?: LocalePair;
form: QuestionForm;
time?: EstimatedTime;
points: number;
}
export interface UpdateQuestionPayload {
id: string;
certificationId?: string;
title?: LocalePair;
description?: LocalePair;
form?: QuestionForm;
time?: EstimatedTime;
points?: number;
isActive?: boolean;
}

View File

@@ -0,0 +1,46 @@
import { baseApi } from '@ema-platform/api';
import type {
Result,
ListResponse,
CreateResultPayload,
UpdateResultPayload,
} from '../types/result';
const resultApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getResults: builder.query<ListResponse<Result>, void>({
query: () => '/results',
providesTags: ['Api'],
}),
getResult: builder.query<Result, string>({
query: (id) => `/results/${id}?i=exam,profile`,
providesTags: ['Api'],
}),
createResult: builder.mutation<Result, CreateResultPayload>({
query: (body) => ({ url: '/results', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateResult: builder.mutation<Result, UpdateResultPayload>({
query: ({ id, ...body }) => ({
url: `/results/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteResult: builder.mutation<void, string>({
query: (id) => ({ url: `/results/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetResultsQuery,
useGetResultQuery,
useLazyGetResultQuery,
useCreateResultMutation,
useUpdateResultMutation,
useDeleteResultMutation,
} = resultApi;

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Stack,
Select,
Divider,
Table,
Text,
Badge,
NumberInput,
Paper,
SimpleGrid,
Group,
TextInput,
Button,
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
import type { Exam } from '../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
export function RecordResultModal({
exam,
opened,
onClose,
}: {
exam: Exam;
opened: boolean;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({});
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
const [remark, setRemark] = useState('');
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
url: '/profiles',
params: { q: 'w=type:=:SEAFARER' },
});
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const [updateResult] = useUpdateResultMutation();
const seafarers = profilesRes?.items ?? [];
const questions = exam.questions ?? [];
const seafarerOptions = seafarers.map((s: any) => ({
value: s.id,
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
}));
const filteredOptions = seafarerSearch
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
: seafarerOptions;
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
const passed = totalScore >= exam.cuttingPoint;
const handleScoreChange = (questionId: string, value: number) => {
setScores((prev) => ({ ...prev, [questionId]: value }));
};
const handleQuestionRemarkChange = (questionId: string, value: string) => {
setQuestionRemarks((prev) => ({ ...prev, [questionId]: value }));
};
const handleSave = async () => {
if (!selectedSeafarerId) {
notify.error(t('result.recordModal.seafarerRequired'));
return;
}
try {
const breakdowns = questions.map((q) => ({
questionId: q.id,
score: scores[q.id] ?? 0,
remark: questionRemarks[q.id] ?? '',
}));
const created = await createResult({
seafarerId: selectedSeafarerId,
examId: exam.id,
resultBreakdowns: breakdowns,
totalScore,
remark: remark ? { en: remark, am: '' } : undefined,
}).unwrap();
if (passed && created.id) {
await updateResult({ id: created.id, status: 'PASSED' }).unwrap();
}
notify.success(t('result.recordModal.saveSuccess'));
setSelectedSeafarerId(null);
setScores({});
setQuestionRemarks({});
setRemark('');
setSeafarerSearch('');
onClose();
} catch {
notify.error(t('result.recordModal.saveError'));
}
};
return (
<Modal opened={opened} onClose={onClose} title={`${t('result.recordModal.title')}${exam.title[locale]}`} size="lg" radius="lg">
<Stack gap="md">
<Select
label={t('result.recordModal.seafarer')}
placeholder={t('result.recordModal.seafarerPlaceholder')}
data={filteredOptions}
value={selectedSeafarerId}
onChange={(v) => {
setSelectedSeafarerId(v);
setScores({});
setQuestionRemarks({});
}}
searchable
onSearchChange={setSeafarerSearch}
size="sm"
required
/>
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('result.recordModal.question')}</Table.Th>
<Table.Th>{t('result.recordModal.maxPoints')}</Table.Th>
<Table.Th>{t('result.recordModal.score')}</Table.Th>
<Table.Th>{t('result.recordModal.remark')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput
value={scores[q.id] ?? 0}
onChange={(v) => handleScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
</Table.Td>
<Table.Td>
<TextInput
placeholder={t('result.recordModal.remarkOptional')}
value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">
<InfoRow label={t('result.recordModal.totalScore')} value={String(totalScore)} />
<InfoRow label={t('result.recordModal.passMark')} value={String(exam.cuttingPoint)} />
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{t('result.recordModal.status')}</Text>
<Group gap={4} mt={2}>
{passed
? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">{t('result.recordModal.passed')}</Text></>
: <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">{t('result.recordModal.failed')}</Text></>}
</Group>
</div>
</SimpleGrid>
</Paper>
<TextInput
label={t('result.recordModal.remarkOptional')}
placeholder={t('result.recordModal.remarkPlaceholder')}
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
size="sm"
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
{t('result.saveResult')}
</Button>
</Group>
</>
)}
{selectedSeafarerId && questions.length === 0 && (
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
{t('result.recordModal.noQuestions')}
</Alert>
)}
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,504 @@
import { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
ThemeIcon,
Box,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconEye,
IconTrash,
IconUser,
IconCertificate,
IconDeviceFloppy,
IconPlus,
IconClipboardList,
IconCircleCheck,
IconCircleX,
IconChartBar,
IconSearch,
} from '@tabler/icons-react';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
};
function ResultStat({
label,
value,
sub,
icon: Icon,
color,
}: {
label: string;
value: string;
sub?: string;
icon: ElementType;
color: string;
}) {
return (
<Paper p="lg" radius="lg" withBorder>
<Group justify="space-between" align="flex-start">
<ThemeIcon size={46} radius="md" variant="light" color={color}>
<Icon size={22} />
</ThemeIcon>
{sub && (
<Badge variant="light" color={color} radius="sm">
{sub}
</Badge>
)}
</Group>
<Text fz={30} fw={800} mt="md" lh={1.1}>
{value}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{label}
</Text>
</Paper>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
export function ResultPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError } = useGetResultsQuery();
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
const exams = examRes?.items ?? [];
const results = data?.items ?? [];
const [deleteResult] = useDeleteResultMutation();
const [updateResult] = useUpdateResultMutation();
const [searchQuery, setSearchQuery] = useState('');
const [examFilter, setExamFilter] = useState<string | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false);
const [pickerExamId, setPickerExamId] = useState<string | null>(null);
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
const [recordExam, setRecordExam] = useState<Exam | null>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
const startRecord = () => {
const ex = exams.find((e) => e.id === pickerExamId);
if (!ex) {
notify.error(t('result.selectExamError'));
return;
}
setRecordExam(ex);
closePicker();
openRecord();
};
const handleRecordClose = () => {
closeRecord();
setRecordExam(null);
setPickerExamId(null);
};
const filtered = results.filter((r) => {
if (examFilter && r.examId !== examFilter) return false;
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
if (r.seafarerId.toLowerCase().includes(q)) return true;
if (r.seafarer) {
const name = `${r.seafarer.firstName} ${r.seafarer.middleName ?? ''} ${r.seafarer.lastName}`.toLowerCase();
if (name.includes(q)) return true;
}
return false;
});
const total = results.length;
const passedCount = results.filter((r) => r.status === 'PASSED').length;
const failedCount = total - passedCount;
const passRate = total ? Math.round((passedCount / total) * 100) : 0;
const avgScore = total
? (results.reduce((s, r) => s + Number(r.totalScore || 0), 0) / total).toFixed(1)
: '0';
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.[locale] ?? '-';
const viewDetail = useCallback((result: Result) => {
fetchDetail(result.id);
setDetailStatus(result.status);
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
openDetail();
}, [fetchDetail, openDetail]);
const handleDetailSave = async () => {
if (!detailResult) return;
setDetailSaving(true);
try {
await updateResult({
id: detailResult.id,
status: detailStatus as 'PASSED' | 'FAILED',
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
resultBreakdowns: detailBreakdowns,
}).unwrap();
notify.success(t('result.updated'));
closeDetail();
} catch {
notify.error(t('result.error'));
} finally {
setDetailSaving(false);
}
};
const handleDetailClose = () => {
closeDetail();
setDetailBreakdowns([]);
setDetailRemark({ en: '', am: '' });
setDetailStatus('PASSED');
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteResult(deleteTarget.id).unwrap();
notify.success(t('result.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('result.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('result.title')}</Title>
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</Group>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
<ResultStat label={t('result.stats.passed')} value={String(passedCount)} sub={`${passRate}%`} icon={IconCircleCheck} color="teal" />
<ResultStat label={t('result.stats.failed')} value={String(failedCount)} sub={`${total ? 100 - passRate : 0}%`} icon={IconCircleX} color="red" />
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid>
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap">
<TextInput
placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
size="sm"
style={{ width: 240 }}
/>
<Select
placeholder={t('result.search.filterByExam')}
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
value={examFilter}
onChange={(v) => setExamFilter(v ?? null)}
size="sm"
style={{ width: 280 }}
clearable
/>
</Group>
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
<Table.Th>{t('result.columns.exam')}</Table.Th>
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
<Table.Th>{t('result.columns.status')}</Table.Th>
<Table.Th>{t('result.columns.date')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
/>
}
>
{t(`result.status.${r.status}`)}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
<Table.Td>
<Group gap="xs">
<Button
size="xs"
variant="subtle"
leftSection={<IconEye size={13} />}
onClick={() => viewDetail(r)}
>
{t('result.action.viewEdit')}
</Button>
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => { setDeleteTarget(r); openDelete(); }}
>
{t('result.action.delete')}
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<Modal
opened={detailOpened}
onClose={handleDetailClose}
title={t('result.detail.title')}
size="xl"
radius="lg"
>
{isDetailLoading ? (
<Center py="xl"><Loader /></Center>
) : detailResult ? (
<Stack gap="md">
{/* Seafarer Profile */}
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconUser size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">{t('result.detail.seafarerProfile')}</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
</SimpleGrid>
</Paper>
{/* Exam Details */}
{detailResult.exam && (
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="violet" radius="xl">
<IconCertificate size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">{t('result.detail.examDetails')}</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
</SimpleGrid>
</Paper>
)}
<Divider />
{/* Editable fields */}
<Select
label={t('result.detail.status')}
data={[
{ value: 'PASSED', label: t('result.status.PASSED') },
{ value: 'FAILED', label: t('result.status.FAILED') },
]}
value={detailStatus}
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
size="sm"
/>
<BilingualInput
label={t('result.detail.remark')}
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
value={detailRemark}
onChange={setDetailRemark}
size="sm"
/>
{detailBreakdowns.length > 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>{t('result.detail.question')}</Table.Th>
<Table.Th>{t('result.detail.max')}</Table.Th>
<Table.Th>{t('result.detail.score')}</Table.Th>
<Table.Th>{t('result.detail.remarkShort')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{detailBreakdowns.map((b, i) => {
const examDetail = exams.find((e) => e.id === detailResult.examId);
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
return (
<Table.Tr key={b.questionId}>
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
<Table.Td>
<Text fz="xs" lineClamp={2} maw={200}>
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
<Table.Td>
<TextInput
size="xs"
type="number"
style={{ width: 80 }}
value={b.score}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="Optional"
value={b.remark ?? ''}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
</Table.Tr>
);})}
</Table.Tbody>
</Table>
</>
)}
<Group justify="flex-end">
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
>
{t('result.save')}
</Button>
</Group>
</Stack>
) : (
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
)}
</Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">{t('result.deleteConfirmText')}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
</Group>
</Modal>
{/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
<Stack gap="md">
<Text fz="sm" c="dimmed">{t('result.detail.selectExam')}</Text>
<Select
label={t('result.detail.exam')}
placeholder={t('result.detail.selectExamPlaceholder')}
data={examOptions}
value={pickerExamId}
onChange={setPickerExamId}
size="sm"
searchable
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
</Group>
</Stack>
</Modal>
{recordExam && (
<RecordResultModal exam={recordExam} opened={recordOpened} onClose={handleRecordClose} />
)}
</Stack>
);
}

View File

@@ -0,0 +1,78 @@
import type { LocalePair } from '../../certification/types/certification';
import type { EstimatedTime } from '../../question/types/question';
import type { QuestionForm } from '../../question/types/question';
export type { QuestionForm };
export type ExamResultStatus = 'PASSED' | 'FAILED';
export interface ResultBreakdown {
questionId: string;
score: number;
remark?: string;
}
export interface Profile {
id: string;
firstName: string;
middleName?: string;
lastName: string;
gender?: string;
dob?: string;
maritalStatus?: string;
type?: string;
isComplete?: boolean;
}
export interface FullExam {
id: string;
certificationId?: string;
title: LocalePair;
direction?: LocalePair | null;
date?: string;
givenTime?: EstimatedTime | null;
type?: string;
form?: QuestionForm;
venue?: string;
administrationMethod?: string;
evaluationMethod?: string;
selectionMethod?: string;
cuttingPoint?: number;
status?: string;
questions?: { id: string; title: LocalePair; form: QuestionForm; points: number }[];
}
export interface Result {
id: string;
seafarerId: string;
seafarer?: { id: string; firstName: string; middleName?: string; lastName: string };
examId: string;
exam?: FullExam;
profile?: Profile;
resultBreakdowns: ResultBreakdown[];
totalScore: number;
remark: { en: string; am: string } | null;
status: ExamResultStatus;
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
total: number;
items: T[];
}
export interface CreateResultPayload {
seafarerId: string;
examId: string;
resultBreakdowns: ResultBreakdown[];
totalScore?: number;
remark?: { en: string; am: string };
}
export interface UpdateResultPayload {
id: string;
resultBreakdowns?: ResultBreakdown[];
totalScore?: number;
remark?: { en: string; am: string };
status?: ExamResultStatus;
}

View File

@@ -0,0 +1,325 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconChevronDown,
IconChevronUp,
IconEye,
IconHeart,
IconId,
IconSearch,
IconShieldCheck,
IconUser,
IconUsers,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
interface Seafarer {
id: string;
name: string;
nationality: string;
dob: string;
rank: string;
seamanBookNo: string;
seamanBookExpiry: string;
btcNo: string | null;
bsidNo: string | null;
medicalExpiry: string;
medicalStatus: 'Valid' | 'Expiring' | 'Expired';
cocCerts: { type: string; no: string; expiry: string }[];
status: 'Active' | 'Inactive' | 'Suspended';
}
const MOCK_SEAFARERS: Seafarer[] = [
{
id: 'SF-2024-0001', name: 'Abebe Girma', nationality: 'Ethiopian', dob: '1990-04-12',
rank: 'Officer of the Watch', seamanBookNo: 'SB-2024-0001', seamanBookExpiry: '2029-03-14',
btcNo: 'BTC-2024-0001', bsidNo: 'BSID-2024-0001',
medicalExpiry: '2026-03-14', medicalStatus: 'Expiring',
cocCerts: [{ type: 'CoC — STCW II/1', no: 'COC-2023-0042', expiry: '2028-06-20' }],
status: 'Active',
},
{
id: 'SF-2024-0002', name: 'Sara Tadesse', nationality: 'Ethiopian', dob: '1994-08-05',
rank: 'Able Seaman', seamanBookNo: 'SB-2024-0002', seamanBookExpiry: '2029-05-10',
btcNo: 'BTC-2024-0002', bsidNo: null,
medicalExpiry: '2027-05-10', medicalStatus: 'Valid',
cocCerts: [],
status: 'Active',
},
{
id: 'SF-2024-0003', name: 'Dawit Bekele', nationality: 'Ethiopian', dob: '1988-01-22',
rank: 'Chief Engineer', seamanBookNo: 'SB-2024-0003', seamanBookExpiry: '2028-08-20',
btcNo: 'BTC-2024-0003', bsidNo: 'BSID-2024-0003',
medicalExpiry: '2025-08-20', medicalStatus: 'Expired',
cocCerts: [
{ type: 'CoC — STCW III/1', no: 'COC-2022-0018', expiry: '2027-08-20' },
{ type: 'CoC — STCW III/2', no: 'COC-2023-0031', expiry: '2028-04-15' },
],
status: 'Active',
},
{
id: 'SF-2023-0088', name: 'Tekle Haile', nationality: 'Ethiopian', dob: '1985-11-30',
rank: 'Master', seamanBookNo: 'SB-2023-0088', seamanBookExpiry: '2028-01-15',
btcNo: 'BTC-2023-0088', bsidNo: 'BSID-2023-0088',
medicalExpiry: '2026-02-28', medicalStatus: 'Expiring',
cocCerts: [{ type: 'CoC — STCW II/2 Master', no: 'COC-2021-0005', expiry: '2026-07-10' }],
status: 'Active',
},
{
id: 'SF-2022-0210', name: 'Yonas Tesfaye', nationality: 'Ethiopian', dob: '1992-06-14',
rank: 'Deck Rating', seamanBookNo: 'SB-2022-0210', seamanBookExpiry: '2027-06-14',
btcNo: null, bsidNo: null,
medicalExpiry: '2026-01-30', medicalStatus: 'Expiring',
cocCerts: [],
status: 'Inactive',
},
];
const RANK_OPTIONS = ['All', 'Master', 'Chief Engineer', 'Officer of the Watch', 'Able Seaman', 'Deck Rating'];
const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
// ---------------------------------------------------------------------------
// Detail modal
// ---------------------------------------------------------------------------
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
if (!sf) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} {sf.id}</Text></Group>}
size="xl"
radius="lg"
>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
<Stack gap={4}>
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Medical Expiry</Text>
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
</Group>
</Stack>
</Paper>
</SimpleGrid>
{sf.cocCerts.length > 0 && (
<Paper withBorder radius="md" p="md">
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
<Table fz="xs" verticalSpacing="xs">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sf.cocCerts.map((c) => (
<Table.Tr key={c.no}>
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeafarerRegistryPage() {
const [search, setSearch] = useState('');
const [rankFilter, setRankFilter] = useState('All');
const [statusFilter, setStatusFilter] = useState('All');
const [medicalFilter, setMedicalFilter] = useState('All');
const [selected, setSelected] = useState<Seafarer | null>(null);
const [filtersOpen, setFiltersOpen] = useState(true);
const filtered = MOCK_SEAFARERS.filter((sf) => {
const q = search.toLowerCase();
const matchSearch = !q || sf.name.toLowerCase().includes(q) || sf.id.toLowerCase().includes(q) || sf.seamanBookNo.toLowerCase().includes(q);
const matchRank = rankFilter === 'All' || sf.rank === rankFilter;
const matchStatus = statusFilter === 'All' || sf.status === statusFilter;
const matchMed = medicalFilter === 'All' || sf.medicalStatus === medicalFilter;
return matchSearch && matchRank && matchStatus && matchMed;
});
const KPI = [
{ label: 'Total Seafarers', value: MOCK_SEAFARERS.length, color: 'blue', icon: IconUsers },
{ label: 'Active', value: MOCK_SEAFARERS.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
{ label: 'Medical Expiring',value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
{ label: 'Medical Expired', value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
{ label: 'With CoC', value: MOCK_SEAFARERS.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
{ label: 'Without BSID', value: MOCK_SEAFARERS.filter((s) => !s.bsidNo).length, color: 'yellow', icon: IconId },
];
return (
<Stack gap="md">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
</div>
{/* KPIs */}
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
{KPI.map((k) => {
const KIcon = k.icon;
return (
<Card key={k.label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size={28} radius="sm" color={k.color} variant="light"><KIcon size={14} /></ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text fz="lg" fw={800} lh={1}>{k.value}</Text>
<Text fz="xs" c="dimmed" lh={1.2} style={{ lineHeight: 1.2 }}>{k.label}</Text>
</div>
</Group>
</Card>
);
})}
</SimpleGrid>
{/* Search + filters */}
<Paper withBorder radius="lg" p="xl">
<Group mb="sm" gap="sm" justify="space-between">
<TextInput
placeholder="Search by name, seafarer ID, or seaman book…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1, minWidth: 200 }}
size="sm"
/>
<Button
variant="subtle"
size="xs"
rightSection={filtersOpen ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Group>
<Collapse in={filtersOpen}>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm" mb="md">
<Select label="Rank" data={RANK_OPTIONS} value={rankFilter} onChange={(v) => setRankFilter(v ?? 'All')} size="sm" />
<Select label="Status" data={STATUS_OPTIONS} value={statusFilter} onChange={(v) => setStatusFilter(v ?? 'All')} size="sm" />
<Select label="Medical Status" data={MEDICAL_OPTIONS} value={medicalFilter} onChange={(v) => setMedicalFilter(v ?? 'All')} size="sm" />
</SimpleGrid>
<Divider mb="md" />
</Collapse>
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Seafarer ID', 'Name', 'Rank', 'Seaman Book', 'BTC', 'BSID', 'Medical', 'CoC/CoP', '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>
{filtered.map((sf) => (
<Table.Tr key={sf.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{sf.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={600}>{sf.name}</Text><Text fz="xs" c="dimmed">{sf.dob}</Text></Table.Td>
<Table.Td><Text fz="xs">{sf.rank}</Text></Table.Td>
<Table.Td>
<Group gap={4}>
<IconBook2 size={11} color="var(--mantine-color-blue-6)" />
<Text fz="xs">{sf.seamanBookNo}</Text>
</Group>
</Table.Td>
<Table.Td>
{sf.btcNo
? <Group gap={4}><IconCertificate size={11} color="var(--mantine-color-teal-6)" /><Text fz="xs">{sf.btcNo}</Text></Group>
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
</Table.Td>
<Table.Td>
{sf.bsidNo
? <Group gap={4}><IconId size={11} color="var(--mantine-color-violet-6)" /><Text fz="xs">{sf.bsidNo}</Text></Group>
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
</Table.Td>
<Table.Td>
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalStatus}</Badge>
<Text fz="xs" c="dimmed">{sf.medicalExpiry}</Text>
</Table.Td>
<Table.Td>
{sf.cocCerts.length > 0
? <Badge color="violet" variant="light" size="xs">{sf.cocCerts.length} cert(s)</Badge>
: <Text fz="xs" c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
<IconEye size={13} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={10} style={{ textAlign: 'center', padding: '2rem' }}>
<Text c="dimmed" fz="sm">No seafarers match your search.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
</Stack>
);
}

View File

@@ -0,0 +1,377 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconEye,
IconFileDescription,
IconHeart,
IconSearch,
IconShieldCheck,
IconUsers,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data
// ---------------------------------------------------------------------------
interface SeamanBookApp {
id: string;
seafarerId: string;
name: string;
email: string;
mobile: string;
nationality: string;
submitted: string;
status: 'Pending' | 'Under Review' | 'Awaiting Docs' | 'Approved' | 'Rejected' | 'Correction Required';
medicalStatus: 'Valid' | 'Expiring' | 'Expired' | 'Missing';
bstComplete: boolean;
bstCount: number;
docsComplete: boolean;
remarks: string;
}
const MOCK_APPS: SeamanBookApp[] = [
{ id: 'SB-APP-2024-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', mobile: '+251 911 234 567', nationality: 'Ethiopian', submitted: '2024-05-10', status: 'Under Review', medicalStatus: 'Expiring', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All documents submitted. Under initial review.' },
{ id: 'SB-APP-2024-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', mobile: '+251 922 345 678', nationality: 'Ethiopian', submitted: '2024-05-12', status: 'Awaiting Docs', medicalStatus: 'Valid', bstComplete: false, bstCount: 3, docsComplete: false, remarks: 'Missing EFA and PSSR certificates.' },
{ id: 'SB-APP-2024-003', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', mobile: '+251 933 456 789', nationality: 'Ethiopian', submitted: '2024-05-14', status: 'Under Review', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'Pending document authenticity check.' },
{ id: 'SB-APP-2024-004', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', mobile: '+251 944 567 890', nationality: 'Ethiopian', submitted: '2024-05-15', status: 'Correction Required', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: false, remarks: 'National ID scan is unclear. Please resubmit.' },
{ id: 'SB-APP-2024-005', seafarerId: 'SF-2024-0005', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', mobile: '+251 955 678 901', nationality: 'Ethiopian', submitted: '2024-05-16', status: 'Pending', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: '' },
{ id: 'SB-APP-2024-006', seafarerId: 'SF-2024-0006', name: 'Meron Alemu', email: 'meron.a@email.com', mobile: '+251 966 789 012', nationality: 'Ethiopian', submitted: '2024-05-18', status: 'Approved', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All verified. Ready to print.' },
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray', 'Under Review': 'yellow', 'Awaiting Docs': 'orange',
Approved: 'teal', Rejected: 'red', 'Correction Required': 'red',
};
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red', Missing: 'red' };
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function AppDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: SeamanBookApp | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
if (!app) return null;
const submit = (action: 'approve' | 'reject' | 'correction') => {
onAction(app.id, action, remarks);
setRemarks('');
setConfirmModal(null);
onClose();
};
return (
<>
<Drawer
opened={opened}
onClose={onClose}
title={`Application ${app.id}`}
position="right"
size="lg"
padding="lg"
>
<Stack gap="md">
<Button
variant="light"
leftSection={<IconEye size={15} />}
fullWidth
onClick={() => { onClose(); onFullReview(app.id); }}
>
Open Full Review Page
</Button>
{/* Seafarer info */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Seafarer Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
['Seafarer ID', app.seafarerId],
['Full Name', app.name],
['Email', app.email],
['Mobile', app.mobile],
['Nationality', app.nationality],
['Submitted', app.submitted],
].map(([label, value]) => (
<Box key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm">{value}</Text>
</Box>
))}
</SimpleGrid>
</Paper>
{/* Eligibility checklist */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Eligibility Verification</Text>
<Stack gap="xs">
{[
{ label: 'Seafarer Profile Complete', ok: true, icon: IconUsers },
{ label: 'National ID / Fayda Uploaded', ok: app.docsComplete, icon: IconFileDescription },
{ label: `Medical Certificate (${app.medicalStatus})`, ok: app.medicalStatus === 'Valid', icon: IconHeart },
{ label: `Basic Safety Training (${app.bstCount}/5)`, ok: app.bstComplete, icon: IconShieldCheck },
{ label: 'All Documents Uploaded', ok: app.docsComplete, icon: IconFileDescription },
].map(({ label, ok, icon: Icon }) => (
<Group key={label} gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Current status */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Text fw={700} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="sm" c="dimmed">{app.remarks}</Text>}
</Paper>
{/* Officer remarks */}
<Textarea
label="Officer Remarks"
placeholder="Add notes or reason for decision…"
minRows={3}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Divider />
{/* Action buttons */}
<Group grow>
<Button
color="teal"
leftSection={<IconCircleCheck size={15} />}
onClick={() => setConfirmModal('approve')}
disabled={!app.docsComplete || !app.bstComplete || app.medicalStatus === 'Expired' || app.medicalStatus === 'Missing'}
>
Approve
</Button>
<Button
color="orange"
variant="light"
leftSection={<IconAlertCircle size={15} />}
onClick={() => setConfirmModal('correction')}
>
Request Correction
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={15} />}
onClick={() => setConfirmModal('reject')}
>
Reject
</Button>
</Group>
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={`Confirm ${confirmModal === 'approve' ? 'Approval' : confirmModal === 'reject' ? 'Rejection' : 'Correction Request'}`}
centered
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? 'Are you sure you want to approve this Seaman Book application? The seafarer will be notified.'
: confirmModal === 'reject'
? 'Are you sure you want to reject this application? Please ensure you have added remarks explaining the reason.'
: 'A correction request will be sent to the seafarer with your remarks. Are you sure?'}
</Text>
{!remarks && confirmModal !== 'approve' && (
<Text fz="xs" c="red" mb="sm">Please add remarks before proceeding.</Text>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
onClick={() => submit(confirmModal!)}
disabled={!remarks && confirmModal !== 'approve'}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeamanBookQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<SeamanBookApp[]>(MOCK_APPS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const stats = {
total: apps.length,
pending: apps.filter((a) => a.status === 'Pending' || a.status === 'Under Review').length,
awaitingDocs: apps.filter((a) => a.status === 'Awaiting Docs').length,
approved: apps.filter((a) => a.status === 'Approved').length,
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>Seaman Book Applications</Title>
<Text fz="sm" c="dimmed">Review and process seafarer Seaman Book applications</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total', value: stats.total, color: 'blue', icon: IconBook2 },
{ label: 'Under Review', value: stats.pending, color: 'yellow', icon: IconClock },
{ label: 'Awaiting Docs', value: stats.awaitingDocs,color: 'orange', icon: IconFileDescription },
{ label: 'Approved', value: stats.approved, color: 'teal', icon: IconCircleCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Application Queue</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, App ID or Seafarer ID…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(280) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
placeholder="All Status"
data={['Pending', 'Under Review', 'Awaiting Docs', 'Correction Required', 'Approved', 'Rejected']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(180) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconBook2 size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No applications found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Seafarer', 'Submitted', 'Medical', 'BST', 'Documents', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.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}>{app.name}</Text>
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
<Table.Td><Badge color={MEDICAL_COLOR[app.medicalStatus]} variant="light" size="xs">{app.medicalStatus}</Badge></Table.Td>
<Table.Td>
<Badge color={app.bstComplete ? 'teal' : 'red'} variant="light" size="xs">
{app.bstCount}/5
</Badge>
</Table.Td>
<Table.Td>
<Badge color={app.docsComplete ? 'teal' : 'orange'} variant="light" size="xs">
{app.docsComplete ? 'Complete' : 'Incomplete'}
</Badge>
</Table.Td>
<Table.Td><Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="xs">{app.status}</Badge></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/applications/${app.id}`)}>
Review
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {apps.length} applications</Text>
</Group>
)}
</Paper>
</Stack>
);
}

View File

@@ -1,96 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { authStorage } from '@ema-platform/auth';
/**
* Same-origin host for the user-management module.
*
* The host app (React 19 / Mantine 8 / Tailwind 3) embeds the module (React 18 /
* Mantine 7 / Tailwind 4) via an iframe so the two never share a React tree,
* router, or CSS — the version mismatch is fully isolated by the document
* boundary. The module is built into apps/backoffice/public/_um and served by
* THIS same server at <origin>/_um/, so there is no second server and no second
* port. Override the mount path with VITE_USER_MANAGEMENT_BASE (default /_um).
*
* SSO: the module and host authenticate against the SAME backend, so the host's
* token is valid in the module. The module posts `UM_REQUEST_AUTH`; we reply with
* our stored token. Route-sync mirrors the module's internal route into the host
* URL (/um/<path>) so a refresh deep-links back to the selected menu.
*/
function readToken(): string | null {
const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
return authStorage.getToken() ?? (match ? decodeURIComponent(match[1]) : null);
}
function readRefreshToken(): string | null {
return authStorage.getRefreshToken() ?? null;
}
export default function UserManagementHostPage() {
const navigate = useNavigate();
const location = useLocation();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Same-origin sub-path the module is served from (matches the module's Vite
// `base` + the apps/backoffice/public/_um build). Same origin ⇒ no second port.
const mountBase = (
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
).replace(/\/$/, '');
const moduleOrigin = window.location.origin;
// Deep-link: the host route is /um/*, so whatever follows /um is the module's
// own route. Compute src ONCE (frozen) so later parent-URL updates don't reload.
const [iframeSrc] = useState(() => {
const sub = location.pathname.replace(/^\/um(?=\/|$)/, '');
return mountBase + sub + location.search;
});
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== moduleOrigin) return;
const data = event.data as { type?: string; path?: string } | undefined;
if (!data) return;
if (data.type === 'UM_REQUEST_AUTH') {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (token && target) {
target.postMessage({ type: 'UM_AUTH_TOKEN', token, refreshToken }, moduleOrigin);
}
return;
}
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
// Allow the module to navigate the host away by using /return/<path>.
// Add a nav item with href: "/return/dashboard" in project.theme.ts
// navItems to send the user back to the host app.
const returnMatch = data.path.match(/^\/return\/(.+)/);
if (returnMatch) {
navigate('/' + returnMatch[1], { replace: true });
return;
}
const target = '/um' + data.path;
if (window.location.pathname + window.location.search !== target) {
navigate(target, { replace: true });
}
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [moduleOrigin, navigate]);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<iframe
ref={iframeRef}
title="User Management"
src={iframeSrc}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useCallback, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNavigate } from 'react-router-dom';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
const UM_OVERRIDES = `
.um-theme-light {
--background: #ffffff !important;
--foreground: #1f2937 !important;
--card: #ffffff !important;
--card-foreground: #1f2937 !important;
--popover: #ffffff !important;
--popover-foreground: #1f2937 !important;
--secondary: #f1f5f9 !important;
--secondary-foreground: #1e293b !important;
--muted: #f1f5f9 !important;
--muted-foreground: #64748b !important;
--accent: var(--primary) !important;
--accent-foreground: var(--primary-foreground) !important;
--border: #e2e8f0 !important;
--input: #e2e8f0 !important;
--sidebar: #ffffff !important;
--sidebar-foreground: #1e293b !important;
--sidebar-accent: #f1f5f9 !important;
--sidebar-accent-foreground: #1e293b !important;
--sidebar-border: #e2e8f0 !important;
--sidebar-ring: var(--primary) !important;
--color-amber-50: #eff6ff !important;
--color-amber-100: #dbeafe !important;
--color-yellow-50: #eff6ff !important;
--color-yellow-100: #dbeafe !important;
}
[class*="bg-[#f7efe8]"] { background-color: #f1f5f9 !important; }
[class*="bg-[#f2ece8]"] { background-color: #f1f5f9 !important; }
[class*="hover:bg-[#f7efe8]"]:hover { background-color: #f1f5f9 !important; }
[class*="hover:bg-[#f2ece8]"]:hover { background-color: #f1f5f9 !important; }
[class*="border-[#e6d8cc]"] { border-color: #e2e8f0 !important; }
[class*="bg-[#fffdfa]"] { background-color: #ffffff !important; }
[class*="bg-[#fcf7f3]"] { background-color: #f8fafc !important; }
[class*="bg-[#fffaf6]"] { background-color: #f8fafc !important; }
[class*="text-[#432319]"] { color: #1e293b !important; }
[class*="text-[#7a6a61]"] { color: #64748b !important; }
[class*="text-[#6b4a3d]"] { color: #475569 !important; }
.um-theme-light [class*="dark:border-gray-900"],
.um-theme-light [class*="dark:border-gray-800"],
.um-theme-light [class*="dark:border-gray-700"],
.um-theme-light [class*="dark:border-gray-600"],
.um-theme-light [class*="dark:border-slate-800"],
.um-theme-light [class*="dark:border-slate-700"] {
border-color: #e2e8f0 !important;
}
`;
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
},
};
const UM_RUNTIME = {
basename: '/um',
apiUrl: import.meta.env.VITE_BASE_API_URL,
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
};
export default function UserManagementPage() {
const containerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const style = document.createElement('style');
style.textContent = UM_OVERRIDES;
document.head.appendChild(style);
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
);
return () => {
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
style.remove();
};
}, []);
return (
<>
<button
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
</>
);
}

View File

@@ -18,7 +18,20 @@ export const am: Translations = {
menu: 'ምናሌ',
dashboard: 'ዳሽቦርድ',
userManagement: 'የተጠቃሚ አስተዳደር',
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
cocQueue: 'የCoC/CoP ወረፋ',
endorsementQueue: 'የማረጋገጫ ወረፋ',
seafarerRegistry: 'የመርከበኞች መዝገብ',
applications: 'ማመልከቻዎች',
paymentConfig: 'የክፍያ ውቅረት',
analytics: 'ትንታኔ',
medicalVerification: 'የህክምና ማረጋገጫ',
locations: 'አካባቢዎች',
configuration: 'ውቅረት',
profile: 'መገለጫ',
questions: 'ጥያቄዎች',
exams: 'ፈተናዎች',
examResults: 'የፈተና ውጤቶች',
collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ',
},
@@ -86,6 +99,143 @@ export const am: Translations = {
},
},
exam: {
title: 'ፈተናዎች',
subtitle: 'ፈተናዎችን ያስተዳድሩ፣ ጥያቄዎችን ይመድቡ እና ውጤቶችን ይከታተሉ',
create: 'ፈተና ይፍጠሩ',
update: 'ፈተና ያዘምኑ',
add: 'ፈተና ይፍጠሩ',
noItems: 'ምንም ፈተናዎች አልተገኙም',
created: 'ፈተና ተፈጥሯል',
updated: 'ፈተና ዘምኗል',
deleted: 'ፈተና ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ፈተናዎችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
delete: 'ሰርዝ',
confirmDelete: 'ፈተና ይሰረዝ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
print: 'ፈተና ያትሙ',
recordResult: 'ውጤት ይመዝግቡ',
manageQuestions: 'ጥያቄዎችን ያስተዳድሩ',
saveAssignments: 'ምደባዎችን ያስቀምጡ',
randomSelect: 'በዘፈቀደ ይምረጡ',
backToExams: 'ወደ ፈተናዎች ይመለሱ',
notFound: 'ፈተና አልተገኘም።',
noQuestionsAssigned: 'ገና ምንም ጥያቄዎች አልተመደቡም።',
columns: {
title: 'ርዕስ',
certification: 'የምስክር ወረቀት',
date: 'ቀን',
type: 'አይነት',
form: 'ቅጽ',
venue: 'ቦታ',
questions: 'ጥያቄዎች',
status: 'ሁኔታ',
},
detail: {
title: 'የፈተና ዝርዝሮች',
certification: 'የምስክር ወረቀት',
type: 'አይነት',
form: 'ቅጽ',
venue: 'ቦታ',
date: 'ቀን',
administration: 'አስተዳደር',
evaluation: 'ግምገማ',
selection: 'ምርጫ',
timeAllowed: 'የተፈቀደ ጊዜ',
passMark: 'የማለፊያ ነጥብ',
totalPoints: 'ጠቅላላ ነጥብ',
questions: 'ጥያቄዎች',
directions: 'መመሪያዎች',
questionLabel: 'ጥያቄ',
questionsSection: 'ጥያቄዎች (ጠቅላላ {{pts}} ነጥብ)',
},
form: {
basicInfo: 'መሠረታዊ መረጃ',
settings: 'ቅንብሮች',
certification: 'የምስክር ወረቀት',
selectCertification: 'የምስክር ወረቀት ይምረጡ',
titleEn: 'ርዕስ (እንግሊዝኛ)',
titleEnPlaceholder: 'የፈተና ርዕስ በእንግሊዝኛ',
titleAm: 'ርዕስ (አማርኛ)',
titleAmPlaceholder: 'የፈተና ርዕስ',
directionEn: 'መመሪያ (እንግሊዝኛ)',
directionEnPlaceholder: 'መመሪያ በእንግሊዝኛ',
directionAm: 'መመሪያ (አማርኛ)',
directionAmPlaceholder: 'መመሪያ በአማርኛ',
examDate: 'የፈተና ቀን',
venue: 'ቦታ',
venuePlaceholder: 'የፈተና ቦታ',
timeAllowed: 'የተፈቀደ ጊዜ',
days: 'ቀናት',
hours: 'ሰአታት',
minutes: 'ደቂቃዎች',
written: 'ጽሑፍ',
oral: 'ቃል',
essay: 'ኢሴይ',
choice: 'ምርጫ',
offline: 'ከመስመር ውጪ',
online: 'በመስመር',
sum: 'ድምር',
average: 'አማካይ',
percentage: 'መቶኛ',
manual: 'በእጅ',
random: 'በዘፈቀደ',
cuttingPoint: 'የማለፊያ ነጥብ',
cuttingPointPlaceholder: 'ለማለፍ ዝቅተኛ ነጥብ',
status: 'ሁኔታ',
statusPlaceholder: 'የፈተና ሁኔታ',
pending: 'በመጠባበቅ ላይ',
active: 'ንቁ',
completed: 'ተጠናቋል',
cancelled: 'ተሰርዟል',
postponed: 'ተላልፏል',
published: 'ታትሟል',
},
assigner: {
title: 'ጥያቄዎችን ለፈተና ይመድቡ',
assignedTitle: 'የተመደቡ ጥያቄዎች',
available: 'የሚገኙ ጥያቄዎች',
assigned: 'የተመደቡ ጥያቄዎች',
search: 'ፈልግ...',
noQuestions: 'ምንም ጥያቄዎች የሉም',
assignSelected: 'የተመረጡትን ይመድቡ ({{count}})',
removeSelected: 'የተመረጡትን ያስወግዱ ({{count}})',
randomHint: 'ከጠቅላላ {{total}} ብቁ ጥያቄዎች ውስጥ በዘፈቀደ ይምረጡ። ምርጫው አጠቃላይ ነጥቦች የማለፊያ ነጥብ ({{pts}}) ላይ እንደሚደርሱ በራስ-ሰር ያረጋግጣል።',
selectCount: 'ብዛት',
},
status: {
PENDING: 'በመጠባበቅ ላይ',
ACTIVE: 'ንቁ',
COMPLETED: 'ተጠናቋል',
CANCELLED: 'ተሰርዟል',
POSTPONED: 'ተላልፏል',
PUBLISHED: 'ታትሟል',
},
type: {
WRITTEN: 'ጽሑፍ',
ORAL: 'ቃል',
},
formType: {
ESSAY: 'ኢሴይ',
CHOICE: 'ምርጫ',
},
admin: {
OFFLINE: 'ከመስመር ውጪ',
ONLINE: 'በመስመር',
},
eval: {
SUM: 'ድምር',
AVERAGE: 'አማካይ',
PERCENTAGE: 'መቶኛ',
},
selection: {
MANUAL: 'በእጅ',
RANDOM: 'በዘፈቀደ',
},
},
location: {
title: 'አካባቢዎች',
hierarchy: 'የአካባቢ ተዋረድ',
@@ -97,9 +247,11 @@ export const am: Translations = {
addTitle: 'አካባቢ ያክሉ',
editTitle: 'አካባቢ ያስተካክሉ',
code: 'ኮድ',
name: 'ስም',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
type: 'አይነት',
level: 'ደረጃ',
parent: 'ወላጅ',
selectType: 'አይነት ይምረጡ',
cancel: 'ሰርዝ',
@@ -168,6 +320,12 @@ export const am: Translations = {
title: 'ባለሁለት ደረጃ ማረጋገጫ',
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
},
layout: {
title: 'አቀማመጥ',
subtitle: 'የማውጫ አቀማመጥ እንዴት እንደሚታይ ይምረጡ።',
top: 'የላይኛው ትሮች',
sidebar: 'የጎን አሞሌ',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
@@ -206,4 +364,221 @@ export const am: Translations = {
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
},
},
certification: {
title: 'የምስክር ወረቀቶች',
subtitle: 'የምስክር ወረቀት አይነቶችን ያስተዳድሩ (ለምሳሌ CoC, CoP)',
add: 'የምስክር ወረቀት ያክሉ',
noItems: 'ምንም የምስክር ወረቀቶች አልተገኙም',
confirmDelete: 'የምስክር ወረቀት ይሰረዝ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
created: 'የምስክር ወረቀት ተፈጥሯል',
updated: 'የምስክር ወረቀት ዘምኗል',
deleted: 'የምስክር ወረቀት ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'የምስክር ወረቀቶችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
delete: 'ሰርዝ',
columns: {
name: 'ስም',
description: 'መግለጫ',
status: 'ሁኔታ',
},
form: {
nameEn: 'ስም (እንግሊዝኛ)',
nameEnPlaceholder: 'የምስክር ወረቀት ስም በእንግሊዝኛ',
nameAm: 'ስም (አማርኛ)',
nameAmPlaceholder: 'የምስክር ወረቀት ስም',
descEn: 'መግለጫ (እንግሊዝኛ)',
descEnPlaceholder: 'የእንግሊዝኛ መግለጫ',
descAm: 'መግለጫ (አማርኛ)',
descAmPlaceholder: 'የአማርኛ መግለጫ',
},
status: {
active: 'ንቁ',
inactive: 'እንቅስቃሴ የሌለ',
},
},
result: {
title: 'የፈተና ውጤቶች',
subtitle: 'የመርከበኞችን የፈተና ውጤቶች እና የውጤት ክፍፍል ይመልከቱ',
record: 'ውጤት ያስመዝግቡ',
noItems: 'ምንም ውጤት አልተገኘም',
noData: 'ምንም መረጃ የለም',
created: 'ውጤት በተሳካ ሁኔታ ተመዝግቧል',
updated: 'ውጤት በተሳካ ሁኔታ ዘምኗል',
deleted: 'ውጤት በተሳካ ሁኔታ ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ውጤቶችን በማምጣት ላይ ስህተት',
cancel: 'ሰርዝ',
delete: 'ሰርዝ',
close: 'ዝጋ',
save: 'አስቀምጥ',
saveResult: 'ውጤት አስቀምጥ',
section: 'ውጤቶች',
selectExamError: 'እባክዎ ፈተና ይምረጡ',
continue: 'ቀጥል',
confirmDelete: 'ውጤት ሰርዝ',
deleteConfirmText: 'እርግጠኛ ነዎት ይህን ውጤት መሰረዝ ይፈልጋሉ?',
stats: {
totalResults: 'ጠቅላላ ውጤቶች',
passed: 'ያለፉ',
failed: 'ያልተሳኩ',
avgScore: 'አማካይ ውጤት',
},
columns: {
seafarer: 'መርከበኛ',
exam: 'ፈተና',
totalScore: 'ጠቅላላ ውጤት',
status: 'ሁኔታ',
date: 'ቀን',
},
detail: {
title: 'የውጤት ዝርዝር',
seafarerProfile: 'የመርከበኛ መገለጫ',
examDetails: 'የፈተና ዝርዝሮች',
fullName: 'ሙሉ ስም',
gender: 'ፆታ',
dateOfBirth: 'የትውልድ ቀን',
maritalStatus: 'የትዳር ሁኔታ',
examTitle: 'የፈተና ርዕስ',
titleAm: 'ርዕስ (አማርኛ)',
type: 'አይነት',
venue: 'ቦታ',
date: 'ቀን',
passMark: 'ማለፊያ ውጤት',
status: 'ሁኔታ',
remark: 'ማስታወሻ',
scoreBreakdown: 'የውጤት ክፍፍል',
question: 'ጥያቄ',
max: 'ከፍተኛ',
score: 'ውጤት',
remarkShort: 'ማስታወሻ',
selectExam: 'ውጤት ለመመዝገብ የሚፈልጉትን ፈተና ይምረጡ።',
exam: 'ፈተና',
selectExamPlaceholder: 'ፈተና ይምረጡ',
},
recordModal: {
title: 'ውጤት ያስመዝግቡ',
seafarer: 'መርከበኛ',
seafarerPlaceholder: 'መርከበኛ ይፈልጉ እና ይምረጡ',
scorePerQuestion: 'በጥያቄ ውጤት',
question: 'ጥያቄ',
maxPoints: 'ከፍተኛ ውጤት',
score: 'ውጤት',
remark: 'ማስታወሻ',
remarkOptional: 'ማስታወሻ (አማራጭ)',
remarkPlaceholder: 'የኦፊሰር ማስታወሻ',
totalScore: 'ጠቅላላ ውጤት',
passMark: 'ማለፊያ ውጤት',
status: 'ሁኔታ',
noQuestions: 'ለዚህ ፈተና ምንም ጥያቄዎች አልተመደቡም። መጀመሪያ ጥያቄዎችን ይመድቡ።',
seafarerRequired: 'እባክዎ መርከበኛ ይምረጡ',
saveSuccess: 'ውጤት ተመዝግቧል',
saveError: 'ውጤቱን ማስቀመጥ አልተሳካም',
passed: 'አልፏል',
failed: 'አልተሳካም',
},
status: {
PASSED: 'አልፏል',
FAILED: 'አልተሳካም',
},
action: {
viewEdit: 'ተመልከት / አስተካክል',
delete: 'ሰርዝ',
},
search: {
seafarer: 'መርከበኛ ይፈልጉ...',
filterByExam: 'በፈተና አጣራ',
allExams: 'ሁሉም ፈተናዎች',
},
},
question: {
title: 'ጥያቄዎች',
pool: 'የጥያቄ ማከማቻ',
filterByCertification: 'በምስክር ወረቀት አጣራ',
noQuestions: 'ምንም ጥያቄዎች አልተገኙም',
addQuestion: 'ጥያቄ ያክሉ',
editQuestion: 'ጥያቄ ያስተካክሉ',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት ይህን ጥያቄ መሰረዝ ይፈልጋሉ?',
created: 'ጥያቄ ተፈጥሯል',
updated: 'ጥያቄ ዘምኗል',
deleted: 'ጥያቄ ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ጥያቄዎችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
delete: 'ሰርዝ',
columns: {
title: 'ርዕስ',
certification: 'የምስክር ወረቀት',
form: 'ቅጽ',
points: 'ነጥብ',
status: 'ሁኔታ',
},
form: {
certification: 'የምስክር ወረቀት',
selectCertification: 'የምስክር ወረቀት ይምረጡ',
titleEn: 'ርዕስ (እንግሊዝኛ)',
titleEnPlaceholder: 'ጥያቄ በእንግሊዝኛ',
titleAm: 'ርዕስ (አማርኛ)',
titleAmPlaceholder: 'ጥያቄ በአማርኛ',
form: 'ቅጽ',
selectForm: 'ቅጽ ይምረጡ',
essay: 'ኢሴይ',
choice: 'ምርጫ',
points: 'ነጥብ',
pointsPlaceholder: 'ነጥብ',
timeAllowed: 'የተፈቀደ ጊዜ',
days: 'ቀናት',
hours: 'ሰአታት',
minutes: 'ደቂቃዎች',
},
status: {
active: 'ንቁ',
inactive: 'እንቅስቃሴ የሌለ',
},
},
configuration: {
title: 'ውቅረት',
departments: 'ክፍሎች',
professions: 'ሙያዎች',
departmentsList: 'ክፍሎች',
professionsList: 'ሙያዎች',
addDepartment: 'ክፍል ያክሉ',
addProfession: 'ሙያ ያክሉ',
name: 'ስም',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
description: 'መግለጫ',
descEn: 'መግለጫ (እንግሊዝኛ)',
descAm: 'መግለጫ (አማርኛ)',
department: 'ክፍል',
selectDepartment: 'ክፍል ይምረጡ',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
edit: 'አስተካክል',
delete: 'ሰርዝ',
created: 'በተሳካ ሁኔታ ተፈጥሯል',
updated: 'በተሳካ ሁኔታ ዘምኗል',
deleted: 'በተሳካ ሁኔታ ተሰርዟል',
error: 'አንድ ስህተት ተፈጥሯል',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
noDepartments: 'ገና ምንም ክፍሎች አልተገለጹም',
noProfessions: 'ገና ምንም ሙያዎች አልተገለጹም',
validation: {
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
departmentRequired: 'ክፍል ያስፈልጋል',
},
},
};

View File

@@ -16,7 +16,20 @@ export const en = {
menu: 'MENU',
dashboard: 'Dashboard',
userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue',
cocQueue: 'CoC / CoP Queue',
endorsementQueue: 'Endorsement Queue',
seafarerRegistry: 'Seafarer Registry',
applications: 'Applications',
paymentConfig: 'Payment Config',
analytics: 'Analytics',
medicalVerification: 'Medical Verification',
locations: 'Locations',
configuration: 'Configuration',
profile: 'Profile',
questions: 'Questions',
exams: 'Examinations',
examResults: 'Exam Results',
collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar',
},
@@ -84,6 +97,143 @@ export const en = {
},
},
exam: {
title: 'Examinations',
subtitle: 'Manage exams, assign questions, and track results',
create: 'Create Exam',
update: 'Update Exam',
add: 'Create Exam',
noItems: 'No exams found',
created: 'Exam created',
updated: 'Exam updated',
deleted: 'Exam deleted',
error: 'Operation failed',
loadError: 'Error loading exams',
cancel: 'Cancel',
delete: 'Delete',
confirmDelete: 'Delete Exam',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
print: 'Print Exam',
recordResult: 'Record Result',
manageQuestions: 'Manage Questions',
saveAssignments: 'Save Assignments',
randomSelect: 'Randomly Select',
backToExams: 'Back to Exams',
notFound: 'Exam not found.',
noQuestionsAssigned: 'No questions assigned yet. Click "Manage Questions" to assign.',
columns: {
title: 'Title',
certification: 'Certification',
date: 'Date',
type: 'Type',
form: 'Form',
venue: 'Venue',
questions: 'Questions',
status: 'Status',
},
detail: {
title: 'Exam Details',
certification: 'Certification',
type: 'Type',
form: 'Form',
venue: 'Venue',
date: 'Date',
administration: 'Administration',
evaluation: 'Evaluation',
selection: 'Selection',
timeAllowed: 'Time Allowed',
passMark: 'Pass Mark',
totalPoints: 'Total Points',
questions: 'Questions',
directions: 'Directions',
questionLabel: 'Question',
questionsSection: 'Questions ({{pts}} pts total)',
},
form: {
basicInfo: 'Basic Info',
settings: 'Settings',
certification: 'Certification',
selectCertification: 'Select certification',
titleEn: 'Title (English)',
titleEnPlaceholder: 'Exam title in English',
titleAm: 'Title (Amharic)',
titleAmPlaceholder: 'የፈተና ርዕስ',
directionEn: 'Direction (English)',
directionEnPlaceholder: 'Instructions in English',
directionAm: 'Direction (Amharic)',
directionAmPlaceholder: 'መመሪያ በአማርኛ',
examDate: 'Exam Date',
venue: 'Venue',
venuePlaceholder: 'Exam venue',
timeAllowed: 'Time Allowed',
days: 'Days',
hours: 'Hours',
minutes: 'Minutes',
written: 'Written',
oral: 'Oral',
essay: 'Essay',
choice: 'Choice',
offline: 'Offline',
online: 'Online',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
manual: 'Manual',
random: 'Random',
cuttingPoint: 'Cutting Point (Pass Mark)',
cuttingPointPlaceholder: 'Minimum score to pass',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
active: 'Active',
completed: 'Completed',
cancelled: 'Cancelled',
postponed: 'Postponed',
published: 'Published',
},
assigner: {
title: 'Assign Questions to Exam',
assignedTitle: 'Assigned Questions',
available: 'Available Questions',
assigned: 'Assigned Questions',
search: 'Search...',
noQuestions: 'No questions',
assignSelected: 'Assign Selected ({{count}})',
removeSelected: 'Remove Selected ({{count}})',
randomHint: 'Randomly select questions from the pool of {{total}} eligible questions. The selection will automatically ensure total points meet the passing mark ({{pts}} pts).',
selectCount: 'Count',
},
status: {
PENDING: 'Pending',
ACTIVE: 'Active',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
POSTPONED: 'Postponed',
PUBLISHED: 'Published',
},
type: {
WRITTEN: 'Written',
ORAL: 'Oral',
},
formType: {
ESSAY: 'Essay',
CHOICE: 'Choice',
},
admin: {
OFFLINE: 'Offline',
ONLINE: 'Online',
},
eval: {
SUM: 'Sum',
AVERAGE: 'Average',
PERCENTAGE: 'Percentage',
},
selection: {
MANUAL: 'Manual',
RANDOM: 'Random',
},
},
location: {
title: 'Locations',
hierarchy: 'Location Hierarchy',
@@ -95,9 +245,11 @@ export const en = {
addTitle: 'Add Location',
editTitle: 'Edit Location',
code: 'Code',
name: 'Name',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
type: 'Type',
level: 'Level',
parent: 'Parent',
selectType: 'Select type',
cancel: 'Cancel',
@@ -166,6 +318,12 @@ export const en = {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
},
layout: {
title: 'Layout',
subtitle: 'Choose how the navigation is displayed.',
top: 'Top tabs',
sidebar: 'Sidebar',
},
notifications: {
title: 'Email notifications',
desc: 'Receive updates about your account activity by email.',
@@ -205,6 +363,223 @@ export const en = {
passwordMismatch: 'Passwords do not match',
},
},
certification: {
title: 'Certifications',
subtitle: 'Manage certification types (e.g. CoC, CoP)',
add: 'Add Certification',
noItems: 'No certifications found',
confirmDelete: 'Delete Certification',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
created: 'Certification created',
updated: 'Certification updated',
deleted: 'Certification deleted',
error: 'Operation failed',
loadError: 'Error loading certifications',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
delete: 'Delete',
columns: {
name: 'Name',
description: 'Description',
status: 'Status',
},
form: {
nameEn: 'Name (English)',
nameEnPlaceholder: 'Certificate name in English',
nameAm: 'Name (Amharic)',
nameAmPlaceholder: 'የምስክር ወረቀት ስም',
descEn: 'Description (English)',
descEnPlaceholder: 'English description',
descAm: 'Description (Amharic)',
descAmPlaceholder: 'የአማርኛ መግለጫ',
},
status: {
active: 'Active',
inactive: 'Inactive',
},
},
result: {
title: 'Exam Results',
subtitle: 'View seafarer examination results and score breakdowns',
record: 'Record Result',
noItems: 'No results found',
noData: 'No data available',
created: 'Result recorded',
updated: 'Result updated',
deleted: 'Result deleted',
error: 'Operation failed',
loadError: 'Error loading results',
cancel: 'Cancel',
delete: 'Delete',
close: 'Close',
save: 'Save',
saveResult: 'Save Result',
section: 'Results',
selectExamError: 'Please select an exam',
continue: 'Continue',
confirmDelete: 'Delete Result',
deleteConfirmText: 'Are you sure you want to delete this result?',
stats: {
totalResults: 'Total Results',
passed: 'Passed',
failed: 'Failed',
avgScore: 'Avg Score',
},
columns: {
seafarer: 'Seafarer',
exam: 'Exam',
totalScore: 'Total Score',
status: 'Status',
date: 'Date',
},
detail: {
title: 'Result Detail',
seafarerProfile: 'Seafarer Profile',
examDetails: 'Exam Details',
fullName: 'Full Name',
gender: 'Gender',
dateOfBirth: 'Date of Birth',
maritalStatus: 'Marital Status',
examTitle: 'Exam Title',
titleAm: 'Title (Amharic)',
type: 'Type',
venue: 'Venue',
date: 'Date',
passMark: 'Pass Mark',
status: 'Status',
remark: 'Remark',
scoreBreakdown: 'Score Breakdown',
question: 'Question',
max: 'Max',
score: 'Score',
remarkShort: 'Remark',
selectExam: 'Choose the exam you want to record a result for.',
exam: 'Exam',
selectExamPlaceholder: 'Select an exam',
},
recordModal: {
title: 'Record Result',
seafarer: 'Seafarer',
seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question',
question: 'Question',
maxPoints: 'Max Points',
score: 'Score',
remark: 'Remark',
remarkOptional: 'Remark (optional)',
remarkPlaceholder: 'Officer remarks',
totalScore: 'Total Score',
passMark: 'Pass Mark',
status: 'Status',
noQuestions: 'No questions assigned to this exam. Assign questions first.',
seafarerRequired: 'Please select a seafarer',
saveSuccess: 'Result recorded',
saveError: 'Failed to save result',
passed: 'PASSED',
failed: 'FAILED',
},
status: {
PASSED: 'PASSED',
FAILED: 'FAILED',
},
action: {
viewEdit: 'View / Edit',
delete: 'Delete',
},
search: {
seafarer: 'Search seafarer...',
filterByExam: 'Filter by exam',
allExams: 'All Exams',
},
},
question: {
title: 'Questions',
pool: 'Question Pool',
filterByCertification: 'Filter by certification',
noQuestions: 'No questions found',
addQuestion: 'Add Question',
editQuestion: 'Edit Question',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete this question?',
created: 'Question created',
updated: 'Question updated',
deleted: 'Question deleted',
error: 'Operation failed',
loadError: 'Error loading questions',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
delete: 'Delete',
columns: {
title: 'Title',
certification: 'Certification',
form: 'Form',
points: 'Points',
status: 'Status',
},
form: {
certification: 'Certification',
selectCertification: 'Select certification',
titleEn: 'Title (English)',
titleEnPlaceholder: 'Question in English',
titleAm: 'Title (Amharic)',
titleAmPlaceholder: 'ጥያቄ በአማርኛ',
form: 'Form',
selectForm: 'Select form',
essay: 'Essay',
choice: 'Choice',
points: 'Points',
pointsPlaceholder: 'Points',
timeAllowed: 'Time Allowed',
days: 'Days',
hours: 'Hours',
minutes: 'Minutes',
},
status: {
active: 'Active',
inactive: 'Inactive',
},
},
configuration: {
title: 'Configuration',
departments: 'Departments',
professions: 'Professions',
departmentsList: 'Departments',
professionsList: 'Professions',
addDepartment: 'Add Department',
addProfession: 'Add Profession',
name: 'Name',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
description: 'Description',
descEn: 'Description (English)',
descAm: 'Description (Amharic)',
department: 'Department',
selectDepartment: 'Select department',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
edit: 'Edit',
delete: 'Delete',
created: 'Created successfully',
updated: 'Updated successfully',
deleted: 'Deleted successfully',
error: 'Something went wrong',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
noDepartments: 'No departments defined yet',
noProfessions: 'No professions defined yet',
validation: {
nameEnRequired: 'English name is required',
nameAmRequired: 'Amharic name is required',
departmentRequired: 'Department is required',
},
},
};
export type Translations = typeof en;

View File

@@ -1,33 +1,48 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { AppShell, rem } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { logout } from '@ema-platform/auth';
import { AppHeader } from '@ema-platform/ui';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import {
IconBook2,
IconChartBar,
IconCreditCard,
IconFileDescription,
IconHeart,
IconLayoutDashboard,
IconUsers,
IconShieldCheck,
IconRubberStamp,
IconSettings,
IconUser,
IconMap,
IconUsers,
IconUserShield,
IconQuestionMark,
IconClipboardList,
IconReport,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { Icon } from '@tabler/icons-react';
interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/locations', label: 'Locations', icon: IconMap },
{ to: '/profile', label: 'Profile', icon: IconUser },
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/applications', label: 'nav.applications', icon: IconFileDescription },
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
{ to: '/profile', label: 'nav.profile', icon: IconUser },
];
const HEADER_HEIGHT = 116;
@@ -38,7 +53,9 @@ export function BackofficeLayout() {
const location = useLocation();
const dispatch = useAppDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure();
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
@@ -62,7 +79,7 @@ export function BackofficeLayout() {
const go = (item: NavItem) => {
if (item.soon) {
notify.info(`${item.label} — coming soon.`);
notify.info(`${t(item.label)} — coming soon.`);
return;
}
if (item.to) {
@@ -70,9 +87,20 @@ export function BackofficeLayout() {
}
};
const handleToggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const isSidebar = layoutMode === 'sidebar';
return (
<AppShell
header={{ height: HEADER_HEIGHT }}
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
navbar={isSidebar ? {
width: collapsed ? 72 : 264,
breakpoint: 'sm',
collapsed: { mobile: !opened },
} : undefined}
padding="lg"
>
<AppShell.Header
@@ -83,11 +111,10 @@ export function BackofficeLayout() {
flexDirection: 'column',
}}
>
{/* Top bar */}
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened}
breadcrumbs={crumbs}
onNavigate={navigate}
@@ -98,63 +125,87 @@ export function BackofficeLayout() {
/>
</div>
{/* Legacy-style tab bar — matching UM AppMenuTabs look */}
<div
{!isSidebar && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{t(item.label)}</span>
</button>
);
})}
</div>
)}
</AppShell.Header>
{isSidebar && (
<AppShell.Navbar
p={0}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
overflow: 'hidden',
transition: 'width 200ms ease',
background: 'var(--mantine-color-body)',
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{item.label}</span>
</button>
);
})}
</div>
</AppShell.Header>
<AppSidebar
navItems={NAV_ITEMS}
collapsed={collapsed}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
/>
</AppShell.Navbar>
)}
<AppShell.Main>
<div key={location.pathname} className="ema-page-enter">
<Outlet />

View File

@@ -12,9 +12,24 @@ import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
import UserManagementPage from '../features/user-management/UserManagementPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
import { LocationPage } from '../features/location/pages/LocationPage';
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
import { CoCQueuePage } from '../features/coc-queue/pages/CoCQueuePage';
import { CoCReviewPage } from '../features/coc-queue/pages/CoCReviewPage';
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
import { QuestionPage } from '../features/question/pages/QuestionPage';
import { ExamPage } from '../features/exam/pages/ExamPage';
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
import { ResultPage } from '../features/result/pages/ResultPage';
const router = createBrowserRouter([
{
@@ -25,7 +40,9 @@ const router = createBrowserRouter([
{ path: '/otp-verify', element: <OTPVerificationPage /> },
],
},
{ path: '/um/*', element: <UserManagementHostPage /> },
{ path: '/um/*', element: <UserManagementPage /> },
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
{
element: <ProtectedRoute />,
children: [
@@ -35,7 +52,22 @@ const router = createBrowserRouter([
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'configuration', element: <ConfigurationPage /> },
{ path: 'locations', element: <LocationPage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
{ path: 'coc-queue', element: <CoCQueuePage /> },
{ path: 'coc-queue/:id', element: <CoCReviewPage /> },
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
{ path: 'payment-config', element: <PaymentConfigPage /> },
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: 'seaman-book-queue', element: <SeamanBookQueuePage /> },
{ path: 'questions', element: <QuestionPage /> },
{ path: 'exams', element: <ExamPage /> },
{ path: 'exams/:id', element: <ExamDetailPage /> },
{ path: 'exam-results', element: <ResultPage /> },
],
},
],

View File

@@ -8,15 +8,17 @@ import {
refreshAccessToken,
logout,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
import { preferencesReducer } from './preferences.slice';
configureAuthStorage('ema-backoffice');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
if (token && user) {
return { token, user, isAuthenticated: true };
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
}
return undefined;
})();
@@ -25,6 +27,7 @@ export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
preferences: preferencesReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,

View File

@@ -0,0 +1,39 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type LayoutMode = 'top' | 'sidebar';
interface PreferencesState {
layoutMode: LayoutMode;
}
const PREFERENCES_KEY = 'ema-backoffice-preferences';
const loadPreferences = (): PreferencesState => {
try {
const stored = localStorage.getItem(PREFERENCES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
return { layoutMode: 'top' };
};
const savePreferences = (state: PreferencesState) => {
try {
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
} catch {}
};
const initialState: PreferencesState = loadPreferences();
const preferencesSlice = createSlice({
name: 'preferences',
initialState,
reducers: {
setLayoutMode(state, action: PayloadAction<LayoutMode>) {
state.layoutMode = action.payload;
savePreferences(state);
},
},
});
export const { setLayoutMode } = preferencesSlice.actions;
export const preferencesReducer = preferencesSlice.reducer;

View File

@@ -7,6 +7,11 @@ import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
document.title = 'EMA Backoffice';
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (_favicon) _favicon.href = '/ema-logo.png';
window.__USER_MANAGEMENT_BRANDING__ = {
appName: 'Ethiopian Maritime Licence',
organizationName: 'Ethiopian Maritime Authority',

View File

@@ -2,20 +2,6 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
function userManagementSpaFallback() {
const rewrite = (req) => {
const url = req.url || '';
if (!url.startsWith('/_um/') && url !== '/_um') return;
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; // real assets pass through
req.url = '/_um/index.html';
};
return {
name: 'user-management-spa-fallback',
configureServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
configurePreviewServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
};
}
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
@@ -24,7 +10,7 @@ export default defineConfig({
host: 'localhost',
},
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},

View File

@@ -1,10 +1,7 @@
import { RouterProvider } from 'react-router-dom';
import { configureIam } from '@tria-plc/iamui-common';
import { AppProviders } from './providers/AppProviders';
import { router } from './router';
// IAM module configuration (used by the isolated /users admin route).
configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() {
return (

View File

@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { authStorage } from '@ema-platform/auth';
interface ProfileGuardProps {
children?: ReactNode;
}
export function ProfileGuard({ children }: ProfileGuardProps) {
const profileId = authStorage.getProfileId();
if (!profileId) {
return <Navigate to="/profile-setup" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,446 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Modal,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconBook2,
IconCalendar,
IconCheck,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconInfoCircle,
IconRefresh,
IconShield,
IconShieldCheck,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface BSTItem {
key: string;
label: string;
shortLabel: string;
description: string;
modelCourse: string;
refreshYears: number;
required: boolean;
}
interface BSTRecord {
key: string;
issuer: string;
issueDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
certNumber: string;
fileName: string;
}
const BST_ITEMS: BSTItem[] = [
{
key: 'pst',
label: 'Personal Survival Techniques',
shortLabel: 'PST',
description: 'Covers lifeboat/life-raft operation, survival at sea, and distress signals.',
modelCourse: 'IMO 1.19',
refreshYears: 5,
required: true,
},
{
key: 'fpff',
label: 'Fire Prevention & Fire Fighting',
shortLabel: 'FPFF',
description: 'Covers fire prevention, detection, and fire-fighting on board vessels.',
modelCourse: 'IMO 1.20',
refreshYears: 5,
required: true,
},
{
key: 'efa',
label: 'Elementary First Aid',
shortLabel: 'EFA',
description: 'Basic first-aid procedures, CPR, and medical emergency response.',
modelCourse: 'IMO 1.13',
refreshYears: 0,
required: true,
},
{
key: 'pssr',
label: 'Personal Safety & Social Responsibility',
shortLabel: 'PSSR',
description: 'Shipboard safety culture, regulations, and working relationships.',
modelCourse: 'IMO 1.21',
refreshYears: 0,
required: true,
},
{
key: 'shp',
label: 'Sexual Harassment Prevention Training',
shortLabel: 'SHPT',
description: 'Awareness and prevention of harassment in the maritime workplace.',
modelCourse: 'EMA National',
refreshYears: 0,
required: true,
},
];
const MOCK_RECORDS: Record<string, BSTRecord> = {
pst: {
key: 'pst',
issuer: 'Bahirdar Maritime School',
issueDate: '2023-04-10',
expiryDate: '2028-04-09',
status: 'Valid',
certNumber: 'PST-2023-BMS-0421',
fileName: 'pst_certificate.pdf',
},
fpff: {
key: 'fpff',
issuer: 'Bahirdar Maritime School',
issueDate: '2023-04-10',
expiryDate: '2028-04-09',
status: 'Valid',
certNumber: 'FPFF-2023-BMS-0421',
fileName: 'fpff_certificate.pdf',
},
};
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
};
function daysUntil(dateStr: string) {
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Upload modal
// ---------------------------------------------------------------------------
function UploadModal({
item,
opened,
onClose,
onUploaded,
}: {
item: BSTItem | null;
opened: boolean;
onClose: () => void;
onUploaded: (key: string, record: BSTRecord) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [issuer, setIssuer] = useState('');
const [certNumber, setCertNumber] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const resetRef = useRef<() => void>(null);
const reset = () => {
setFile(null); setIssuer(''); setCertNumber(''); setIssueDate(''); setExpiryDate('');
resetRef.current?.();
};
const handleSubmit = async () => {
if (!file || !issuer || !certNumber || !issueDate) {
notify.error('Please fill all required fields and upload the certificate.');
return;
}
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1000));
setSubmitting(false);
onUploaded(item!.key, {
key: item!.key,
issuer,
issueDate,
expiryDate: expiryDate || '',
status: 'Pending Verification',
certNumber,
fileName: file.name,
});
notify.success(`${item!.shortLabel} certificate submitted for verification.`);
reset();
onClose();
};
return (
<Modal opened={opened} onClose={() => { reset(); onClose(); }} title={`Upload ${item?.label}`} size="md" centered>
{item && (
<Stack gap="sm">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">{item.description} Model Course: <strong>{item.modelCourse}</strong></Text>
</Alert>
<TextInput label="Issuing Institution" placeholder="e.g. Bahirdar Maritime School" required value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} size="sm" />
<TextInput label="Certificate Number" placeholder="e.g. PST-2024-001" required value={certNumber} onChange={(e) => setCertNumber(e.currentTarget.value)} size="sm" />
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" required value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" />
{item.refreshYears > 0 && (
<TextInput label={`Expiry Date (${item.refreshYears}-yr refresh)`} 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>
{file ? (
<Card withBorder radius="sm" p="xs">
<Group gap="xs">
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" flex={1} truncate>{file.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setFile(null); resetRef.current?.(); }}>
<IconTrash size={12} />
</Button>
</Group>
</Card>
) : (
<FileButton resetRef={resetRef} onChange={setFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={13} />} fullWidth {...props}>
Choose File (PDF / JPG / PNG)
</Button>
)}
</FileButton>
)}
</div>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={() => { reset(); onClose(); }}>Cancel</Button>
<Button onClick={handleSubmit} loading={submitting} leftSection={<IconCheck size={14} />}>Submit</Button>
</Group>
</Stack>
)}
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function BasicSafetyTrainingPage() {
const [records, setRecords] = useState<Record<string, BSTRecord>>(MOCK_RECORDS);
const [modalItem, setModalItem] = useState<BSTItem | null>(null);
const doneCount = BST_ITEMS.filter((i) => records[i.key]).length;
const allDone = doneCount === BST_ITEMS.length;
const handleUploaded = (key: string, record: BSTRecord) => {
setRecords((prev) => ({ ...prev, [key]: record }));
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Basic Safety Training</Title>
<Text fz="sm" c="dimmed">
All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
</Text>
</div>
<Badge
size="lg"
variant="light"
color={allDone ? 'teal' : 'orange'}
leftSection={allDone ? <IconShieldCheck size={14} /> : <IconAlertTriangle size={14} />}
>
{doneCount} / {BST_ITEMS.length} Complete
</Badge>
</Group>
{/* Overall progress */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="sm">
<Text fw={700}>Overall Completion</Text>
<Text fz="sm" c={allDone ? 'teal' : 'orange'} fw={600}>{Math.round((doneCount / BST_ITEMS.length) * 100)}%</Text>
</Group>
<Progress value={(doneCount / BST_ITEMS.length) * 100} color={allDone ? 'teal' : 'orange'} size="md" radius="xl" mb="md" />
{!allDone && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="sm">
You need all 5 certificates to apply for a Seaman Book. Missing: <strong>{BST_ITEMS.filter((i) => !records[i.key]).map((i) => i.shortLabel).join(', ')}</strong>
</Text>
</Alert>
)}
{allDone && (
<Alert variant="light" color="teal" icon={<IconShieldCheck size={15} />} p="sm">
<Text fz="sm">
All 5 BST training certificates are complete. You can now apply for your <strong>Seaman Book & BTC</strong>
the Basic Training Certificate (BTC) is issued by EMA alongside your Seaman Book after your application is approved.
</Text>
</Alert>
)}
</Paper>
{/* Certificate cards */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_ITEMS.map((item) => {
const rec = records[item.key];
const days = rec?.expiryDate ? daysUntil(rec.expiryDate) : null;
const needsRefresh = item.refreshYears > 0;
return (
<Card
key={item.key}
withBorder
radius="md"
p="md"
style={{
borderColor: rec
? rec.status === 'Valid' ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-orange-4)'
: 'var(--mantine-color-red-3)',
borderStyle: rec ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" mb="sm" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant={rec ? 'filled' : 'light'}
color={rec ? STATUS_COLOR[rec.status] : 'red'}
>
{rec ? <IconShieldCheck size={20} /> : <IconShield size={20} />}
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{item.shortLabel}</Text>
<Text fz="xs" c="dimmed">{item.modelCourse}</Text>
</div>
</Group>
{rec ? (
<Badge color={STATUS_COLOR[rec.status]} variant="light" size="sm">{rec.status}</Badge>
) : (
<Badge color="red" variant="light" size="sm">Missing</Badge>
)}
</Group>
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{item.label}</Text>
{rec ? (
<Stack gap={6}>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Certificate No.</Text>
<Text fz="xs" fw={600}>{rec.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issuer</Text>
<Text fz="xs" ta="right" maw={140} truncate>{rec.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs">{formatDate(rec.issueDate)}</Text>
</Group>
{needsRefresh && rec.expiryDate && (
<>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={600} c={days !== null && days <= 180 ? 'orange' : undefined}>
{formatDate(rec.expiryDate)}
</Text>
</Group>
{days !== null && (
<Progress
value={Math.max(0, Math.min(100, (days / (item.refreshYears * 365)) * 100))}
color={days <= 90 ? 'red' : days <= 180 ? 'orange' : 'teal'}
size="xs"
radius="xl"
/>
)}
</>
)}
{needsRefresh && (
<Badge size="xs" variant="dot" color="blue" mt={2}>
<IconRefresh size={10} /> {item.refreshYears}-year refresh required
</Badge>
)}
<Group gap="xs" mt="xs">
<Button size="xs" variant="light" leftSection={<IconDownload size={12} />} flex={1}>
Download
</Button>
<Button size="xs" variant="subtle" color="orange" leftSection={<IconUpload size={12} />} flex={1} onClick={() => setModalItem(item)}>
Update
</Button>
</Group>
</Stack>
) : (
<Stack gap="xs">
<Text fz="xs" c="dimmed" lh={1.4}>{item.description}</Text>
{needsRefresh && (
<Badge size="xs" variant="dot" color="blue">
Requires {item.refreshYears}-year refresh
</Badge>
)}
<Button
size="xs"
leftSection={<IconUpload size={13} />}
onClick={() => setModalItem(item)}
fullWidth
mt="xs"
>
Upload Certificate
</Button>
</Stack>
)}
</Card>
);
})}
</SimpleGrid>
{/* Info */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About Basic Safety Training (STCW VI/1)</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
Basic Safety Training is mandatory for <strong>all seafarers</strong> regardless of department (Deck, Engine, or Catering).
PST and FPFF certificates require evidence of maintained competence every <strong>5 years</strong>.
EFA and PSSR do not have a mandatory 5-year repeat under STCW.
</Text>
</Box>
<Box>
<Text fz="xs" c="dimmed" lh={1.6}>
Certificates must be from <strong>EMA-approved training institutions</strong> (e.g. Bahirdar Maritime School, Babugaya Maritime School).
EMA officers will verify authenticity before approving your Seaman Book application.
</Text>
</Box>
</SimpleGrid>
</Paper>
{/* Upload modal */}
<UploadModal
item={modalItem}
opened={!!modalItem}
onClose={() => setModalItem(null)}
onUploaded={handleUploaded}
/>
</Stack>
);
}

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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import {
Alert,
Badge,
Card,
Center,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
@@ -10,23 +13,59 @@ import {
Title,
UnstyledButton,
useMantineTheme,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconChevronRight,
IconClipboardList,
IconFileCheck,
IconHeart,
IconLifebuoy,
IconShip,
IconShieldCheck,
IconUserPlus,
IconBell,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
// ---------------------------------------------------------------------------
// Mock profile completeness — replace with real store/API data
// ---------------------------------------------------------------------------
const PROFILE_STEPS = [
{ label: 'Personal Information', done: true },
{ label: 'Document Upload', done: true },
{ label: 'Medical Certificate', done: false },
{ label: 'Basic Safety Training', done: false },
{ label: 'Seaman Book', done: false },
];
const BST_ITEMS = [
{ label: 'Personal Survival Techniques (PST)', done: true, expiry: '2028-04-10' },
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true, expiry: '2028-04-10' },
{ label: 'Elementary First Aid (EFA)', done: false, expiry: null },
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: false, expiry: null },
{ label: 'Sexual Harassment Prevention', done: false, expiry: null },
];
const ALERTS = [
{ id: 1, type: 'warning', message: 'Medical certificate expires in 45 days. Please renew before it lapses.', route: '/medical-certificate' },
{ id: 2, type: 'info', message: '3 Basic Safety Training certificates are missing. Complete them to apply for a Seaman Book.', route: '/basic-safety-training' },
];
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
const doneSteps = PROFILE_STEPS.filter((s) => s.done).length;
const completeness = Math.round((doneSteps / PROFILE_STEPS.length) * 100);
const bstDone = BST_ITEMS.filter((b) => b.done).length;
return (
<Stack gap="lg">
{/* ---- Hero banner ------------------------------------------- */}
{/* Hero */}
<Paper
radius="lg"
p="xl"
@@ -36,11 +75,11 @@ export function DashboardPage() {
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome to the EMA Portal
Welcome to the EMA Seafarer Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
Manage your seafarer profile, submit applications, and track your
maritime credentials all in one place.
Manage your seafarer profile, track certificates, apply for your Seaman Book
and monitor your maritime credentials all in one place.
</Text>
</Stack>
</Stack>
@@ -55,54 +94,144 @@ export function DashboardPage() {
</Group>
</Paper>
{/* ---- Quick actions ----------------------------------------- */}
{/* Alerts */}
{ALERTS.map((alert) => (
<Alert
key={alert.id}
variant="light"
color={alert.type === 'warning' ? 'orange' : 'blue'}
icon={alert.type === 'warning' ? <IconAlertCircle size={17} /> : <IconBell size={17} />}
style={{ cursor: 'pointer' }}
onClick={() => navigate(alert.route)}
>
{alert.message}
</Alert>
))}
{/* Status cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatusCard label="Profile Complete" value={`${completeness}%`} icon={IconUserPlus} color="blue" />
<StatusCard label="BST Certificates" value={`${bstDone} / ${BST_ITEMS.length}`} icon={IconShieldCheck} color="teal" />
<StatusCard label="Medical Status" value="Expiring" icon={IconHeart} color="orange" />
<StatusCard label="Seaman Book" value="Not Applied" icon={IconBook2} color="gray" />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{/* Profile completeness */}
<Paper withBorder radius="lg" p="lg">
<Title order={4} mb="md">
Quick actions
</Title>
<Group justify="space-between" mb="md">
<Text fw={700}>Registration Checklist</Text>
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
{completeness}% complete
</Badge>
</Group>
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
<Stack gap="xs">
<QuickAction
icon={IconUserPlus}
color="emaPrimary"
label="Register a new seafarer"
onClick={() => navigate('/seafarer-registration')}
/>
<QuickAction
icon={IconLifebuoy}
color="orange"
label="Contact support"
onClick={() => navigate('/support')}
/>
{PROFILE_STEPS.map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon
size={20}
radius="xl"
variant={step.done ? 'filled' : 'light'}
color={step.done ? 'teal' : 'gray'}
>
<IconFileCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'} td={step.done ? undefined : undefined}>
{step.label}
</Text>
{step.done && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
))}
</Stack>
</Paper>
{/* BST tracker */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700}>Basic Safety Training</Text>
<Badge variant="light" color={bstDone === BST_ITEMS.length ? 'teal' : 'orange'}>
{bstDone} / {BST_ITEMS.length}
</Badge>
</Group>
<Stack gap="xs">
{BST_ITEMS.map((item) => (
<Group key={item.label} gap="xs" wrap="nowrap">
<ThemeIcon size={20} radius="xl" variant={item.done ? 'filled' : 'light'} color={item.done ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
<IconShieldCheck size={12} />
</ThemeIcon>
<Text fz="sm" flex={1} c={item.done ? undefined : 'dimmed'} style={{ lineHeight: 1.3 }}>
{item.label}
</Text>
{item.done && item.expiry && (
<Text fz="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>exp {item.expiry}</Text>
)}
{!item.done && (
<Badge size="xs" color="red" variant="light">Missing</Badge>
)}
</Group>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Quick actions */}
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Quick Actions</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm">
<QuickAction icon={IconUserPlus} color="emaPrimary" label="New Seafarer Registration" sub="Register a new seafarer profile" onClick={() => navigate('/seafarer-registration')} />
<QuickAction icon={IconBook2} color="blue" label="Apply for Seaman Book" sub="Submit your Seaman Book application" onClick={() => navigate('/seaman-book')} />
<QuickAction icon={IconShieldCheck} color="teal" label="Certificates (CoC / CoP)" sub="Apply for STCW certificates" onClick={() => navigate('/certificates')} />
<QuickAction icon={IconHeart} color="red" label="Medical Certificate" sub="Upload or renew your medical certificate" onClick={() => navigate('/documents')} />
<QuickAction icon={IconClipboardList} color="violet" label="Document Vault" sub="Manage all your uploaded documents" onClick={() => navigate('/documents')} />
<QuickAction icon={IconLifebuoy} color="orange" label="Help & Support" sub="Get assistance from EMA staff" onClick={() => navigate('/support')} />
</SimpleGrid>
</Paper>
</Stack>
);
}
function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fz="xl" fw={700} lh={1}>{value}</Text>
<Text fz="xs" c="dimmed" mt={4}>{label}</Text>
</div>
<ThemeIcon variant="light" color={color} size={46} radius="md">
<Icon size={22} stroke={1.6} />
</ThemeIcon>
</Group>
</Card>
);
}
function QuickAction({
icon: ActionIconCmp,
color,
label,
sub,
onClick,
}: {
icon: Icon;
color: string;
label: string;
sub: string;
onClick: () => void;
}) {
return (
<UnstyledButton onClick={onClick}>
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md">
<ActionIconCmp size={19} />
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
<ActionIconCmp size={20} />
</ThemeIcon>
<Text fz="sm" fw={500} flex={1}>
{label}
</Text>
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
</div>
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
</Group>
</Card>
</UnstyledButton>

View File

@@ -0,0 +1,487 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Menu,
Modal,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconCircleCheck,
IconCloudDownload,
IconDotsVertical,
IconDownload,
IconEye,
IconFileCheck,
IconFileDescription,
IconHeart,
IconId,
IconPhoto,
IconSchool,
IconSearch,
IconShieldCheck,
IconTrash,
IconUpload,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// EMA-issued / system-generated documents
// ---------------------------------------------------------------------------
interface IssuedDoc {
key: string;
label: string;
category: 'certificate' | 'book';
color: string;
icon: typeof IconBook2;
issuedDate: string;
expiryDate: string | null;
status: 'issued' | 'pending' | 'expired';
description: string;
}
const ISSUED_DOCS: IssuedDoc[] = [
{
key: 'seaman-book',
label: 'Seaman Book',
category: 'book',
color: 'blue',
icon: IconBook2,
issuedDate: '2024-06-01',
expiryDate: '2029-06-01',
status: 'issued',
description: 'Official EMA-issued seafarer identification document. Valid for 5 years.',
},
{
key: 'btc',
label: 'Basic Training Certificate (BTC)',
category: 'certificate',
color: 'teal',
icon: IconCertificate,
issuedDate: '2024-06-01',
expiryDate: '2029-06-01',
status: 'issued',
description: 'EMA-issued BTC certifying completion of all 5 basic safety training courses.',
},
{
key: 'medical',
label: 'Medical Fitness Certificate',
category: 'certificate',
color: 'pink',
icon: IconHeart,
issuedDate: '2024-03-20',
expiryDate: '2026-03-20',
status: 'issued',
description: 'Medical fitness certificate from an EMA-approved medical centre.',
},
];
// ---------------------------------------------------------------------------
// BTC sub-certificates (the 5 training certs that qualify you for BTC)
// ---------------------------------------------------------------------------
interface BtcCert {
key: string;
short: string;
label: string;
certNumber: string;
issuer: string;
issueDate: string;
expiryDate: string | null;
}
const BTC_CERTS: BtcCert[] = [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-15', expiryDate: '2029-01-15' },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-16', expiryDate: '2029-01-16' },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: null },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: null },
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: null },
];
const STATUS_COLOR = { issued: 'teal', pending: 'yellow', expired: 'red' } as const;
const STATUS_LABEL = { issued: 'Issued', pending: 'Pending', expired: 'Expired' } as const;
// Demo PDF for preview
const DEMO_PDF =
'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
// ---------------------------------------------------------------------------
// Uploaded documents (user-provided supporting docs)
// ---------------------------------------------------------------------------
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
category: 'Identity' | 'Education' | 'Photo';
accept: string;
}
interface UploadedDoc {
key: string;
file: File;
uploadedAt: string;
url: string;
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'nationalId', label: 'National ID / Fayda', description: 'Front and back of your national identity card', required: true, icon: IconId, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'passport', label: 'Passport', description: 'Bio-data page of a valid passport', required: false, icon: IconFileDescription, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5 cm', required: true, icon: IconPhoto, category: 'Photo', accept: 'image/jpeg,image/png' },
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification certificate', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
{ key: 'transcript', label: 'Academic Transcript', description: 'Official academic transcript from institution', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
];
const UPLOAD_CATEGORIES = ['All', 'Identity', 'Photo', 'Education'] as const;
type UploadCategory = typeof UPLOAD_CATEGORIES[number];
const CAT_COLOR: Record<string, string> = { Identity: 'blue', Photo: 'violet', Education: 'teal' };
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function DocumentVaultPage() {
const [docs, setDocs] = useState<Record<string, UploadedDoc | null>>(() =>
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<UploadCategory>('All');
const [previewDoc, setPreviewDoc] = useState<{ name: string; url: string; isImage: boolean } | null>(null);
const resetRefs = useRef<Record<string, (() => void) | null>>({});
const handleUpload = (key: string) => (file: File | null) => {
if (!file) return;
if (file.size > 5 * 1024 * 1024) { notify.error('File exceeds 5MB limit.'); return; }
const url = URL.createObjectURL(file);
setDocs((prev) => ({ ...prev, [key]: { key, file, uploadedAt: new Date().toLocaleDateString('en-GB'), url } }));
notify.success(`${file.name} uploaded.`);
};
const handleRemove = (key: string) => {
const doc = docs[key];
if (doc) URL.revokeObjectURL(doc.url);
setDocs((prev) => ({ ...prev, [key]: null }));
resetRefs.current[key]?.();
notify.info('Document removed.');
};
const filtered = DOC_SLOTS.filter((slot) => {
const matchCat = category === 'All' || slot.category === category;
const matchSearch = !search || slot.label.toLowerCase().includes(search.toLowerCase());
return matchCat && matchSearch;
});
const uploadedCount = Object.values(docs).filter(Boolean).length;
return (
<Stack gap="md">
<div>
<Title order={3}>My Documents</Title>
<Text fz="sm" c="dimmed">All your EMA-issued certificates and uploaded supporting documents</Text>
</div>
<Tabs defaultValue="issued" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="issued" leftSection={<IconFileCheck size={16} />}>
Certificates & Issued Documents
</Tabs.Tab>
<Tabs.Tab value="uploaded" leftSection={<IconUpload size={16} />}>
Uploaded Documents
<Badge size="xs" variant="light" color="blue" ml={6}>{uploadedCount} / {DOC_SLOTS.length}</Badge>
</Tabs.Tab>
</Tabs.List>
{/* ── Certificates & Issued Documents ──────────────────────────── */}
<Tabs.Panel value="issued">
<Stack gap="xl">
{/* EMA-issued documents */}
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="gray.6">EMA Issued Documents</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{ISSUED_DOCS.map((doc) => {
const DocIcon = doc.icon;
return (
<Card key={doc.key} withBorder radius="lg" p="md"
style={{
borderColor: doc.status === 'issued'
? `var(--mantine-color-${doc.color}-3)`
: doc.status === 'expired'
? 'var(--mantine-color-red-3)'
: 'var(--mantine-color-yellow-3)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<ThemeIcon size="xl" variant="light" color={doc.color} radius="lg">
<DocIcon size={22} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={700} fz="sm" lh={1.2}>{doc.label}</Text>
<Badge size="xs" variant="light" color={STATUS_COLOR[doc.status]} mt={3}>
{STATUS_LABEL[doc.status]}
</Badge>
</div>
</Group>
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{doc.description}</Text>
<Stack gap={3} mb="sm">
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs" fw={500}>{doc.issuedDate}</Text>
</Group>
{doc.expiryDate && (
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={500}>{doc.expiryDate}</Text>
</Group>
)}
</Stack>
<Divider mb="sm" />
<Group gap="xs">
<Button size="xs" variant="light" color={doc.color} leftSection={<IconEye size={13} />} style={{ flex: 1 }}
onClick={() => setPreviewDoc({ name: doc.label, url: DEMO_PDF, isImage: false })}>
View
</Button>
<Button size="xs" variant="subtle" leftSection={<IconCloudDownload size={13} />}
component="a" href={DEMO_PDF} download={`${doc.label}.pdf`}>
Download
</Button>
</Group>
</Card>
);
})}
</SimpleGrid>
</div>
{/* BTC training certs — all 5 listed */}
<div>
<Group gap="sm" mb="sm" align="center">
<ThemeIcon size="md" variant="light" color="teal" radius="md">
<IconShieldCheck size={16} stroke={1.5} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm" tt="uppercase" c="gray.6">BTC Training Certificates (5/5)</Text>
<Text fz="xs" c="dimmed">Submitted training certificates that qualified you for the BTC</Text>
</div>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BTC_CERTS.map((cert) => (
<Card key={cert.key} withBorder radius="md" p="md"
style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="teal" radius="md">
<IconShieldCheck size={16} stroke={1.5} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} align="center">
<Text fw={700} fz="sm">{cert.short}</Text>
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
</div>
</Group>
<Stack gap={3} mb="xs">
<Group justify="space-between">
<Text fz="xs" c="dimmed">Cert No.</Text>
<Text fz="xs" fw={500}>{cert.certNumber}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issuer</Text>
<Text fz="xs" fw={500} style={{ textAlign: 'right', maxWidth: rem(140) }}>{cert.issuer}</Text>
</Group>
<Group justify="space-between">
<Text fz="xs" c="dimmed">Issued</Text>
<Text fz="xs" fw={500}>{cert.issueDate}</Text>
</Group>
{cert.expiryDate && (
<Group justify="space-between">
<Text fz="xs" c="dimmed">Expires</Text>
<Text fz="xs" fw={500}>{cert.expiryDate}</Text>
</Group>
)}
</Stack>
<Button size="xs" variant="light" color="teal" leftSection={<IconEye size={13} />} fullWidth
onClick={() => setPreviewDoc({ name: `${cert.short} Certificate`, url: DEMO_PDF, isImage: false })}>
View Certificate
</Button>
</Card>
))}
</SimpleGrid>
</div>
</Stack>
</Tabs.Panel>
{/* ── Uploaded supporting documents ─────────────────────────────── */}
<Tabs.Panel value="uploaded">
<Stack gap="md">
{/* Category summary */}
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
{(['Identity', 'Photo', 'Education'] as const).map((cat) => {
const slots = DOC_SLOTS.filter((s) => s.category === cat);
const done = slots.filter((s) => docs[s.key]).length;
return (
<Card key={cat} withBorder radius="md" p="sm" style={{ cursor: 'pointer' }} onClick={() => setCategory(cat)}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={CAT_COLOR[cat]} size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<div>
<Text fz="xs" c="dimmed">{cat}</Text>
<Text fz="sm" fw={700}>{done}/{slots.length}</Text>
</div>
</Group>
</Card>
);
})}
</SimpleGrid>
{/* Filters */}
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search documents…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(220) }}
rightSection={search ? (
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon>
) : null}
/>
<Group gap={6}>
{UPLOAD_CATEGORIES.map((cat) => (
<Button key={cat} size="xs"
variant={category === cat ? 'filled' : 'light'}
color={cat === 'All' ? 'gray' : CAT_COLOR[cat] ?? 'gray'}
onClick={() => setCategory(cat)}>
{cat}
</Button>
))}
</Group>
</Group>
{/* Document cards */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{filtered.map((slot) => {
const doc = docs[slot.key];
const SlotIcon = slot.icon;
const resetRef = { current: null as (() => void) | null };
return (
<Card key={slot.key} withBorder radius="md" p="md"
style={{
borderStyle: doc ? 'solid' : 'dashed',
borderColor: doc
? 'var(--mantine-color-teal-5)'
: slot.required
? 'var(--mantine-color-orange-4)'
: 'var(--mantine-color-default-border)',
}}
>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
background: doc ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SlotIcon size={20} color={doc ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4}>
<Text fw={600} fz="sm" lh={1.3}>{slot.label}</Text>
{slot.required && !doc && <Text span c="red" fz="xs">*</Text>}
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{slot.description}</Text>
</div>
<Badge size="xs" variant="light" color={CAT_COLOR[slot.category]}>{slot.category}</Badge>
</Group>
{doc ? (
<Group gap="xs" align="center">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{doc.file.name}</Text>
<Text fz="xs" c="dimmed">{doc.uploadedAt}</Text>
<Menu position="bottom-end" shadow="sm" width={140} withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="xs">
<IconDotsVertical size={13} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEye size={13} />}
onClick={() => setPreviewDoc({ name: doc.file.name, url: doc.url, isImage: doc.file.type.startsWith('image/') })}>
Preview
</Menu.Item>
<Menu.Item leftSection={<IconDownload size={13} />} component="a" href={doc.url} download={doc.file.name}>
Download
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconTrash size={13} />} color="red" onClick={() => handleRemove(slot.key)}>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={handleUpload(slot.key)} accept={slot.accept}>
{(props) => {
resetRefs.current[slot.key] = resetRef.current;
return (
<Button size="xs" variant="light" leftSection={<IconUpload size={13} />} fullWidth {...props}>
Upload Document
</Button>
);
}}
</FileButton>
)}
</Card>
);
})}
</SimpleGrid>
{filtered.length === 0 && (
<Paper withBorder radius="md" p="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconFileDescription size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No documents match your search.</Text>
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setCategory('All'); }}>
Clear filters
</Button>
</Paper>
)}
</Stack>
</Tabs.Panel>
</Tabs>
{/* Preview modal */}
<Modal
opened={!!previewDoc}
onClose={() => setPreviewDoc(null)}
title={<Text fw={700}>{previewDoc?.name}</Text>}
size="xl"
centered
styles={{ body: { padding: 0, minHeight: rem(500) } }}
>
{previewDoc && (
previewDoc.isImage
? <img src={previewDoc.url} alt={previewDoc.name} style={{ width: '100%', borderRadius: rem(8) }} />
: <iframe src={previewDoc.url} title={previewDoc.name} style={{ width: '100%', height: rem(500), border: 'none' }} />
)}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,436 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
FileInput,
Group,
List,
Modal,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconEye,
IconFileDescription,
IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react';
// ---------------------------------------------------------------------------
// Mock data — existing endorsement applications
// ---------------------------------------------------------------------------
const MOCK_ENDORSEMENTS = [
{
id: 'END-APP-2025-001',
cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
foreignCocNo: 'PHL-COC-2022-0045',
issuingCountry: 'Philippines',
submitted: '2025-04-05',
status: 'Document Verification',
statusColor: 'blue',
statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
},
];
const MOCK_ISSUED = [
{
id: 'EMA-END-2024-012',
cocType: 'Chief Mate — STCW II/2',
foreignCocNo: 'GRC-COC-2019-0033',
issuingCountry: 'Greece',
endorsementNo: 'EMA-END-2024-012',
issued: '2024-08-10',
expiry: '2029-06-15',
status: 'Valid',
statusColor: 'teal',
},
];
// blank PDF
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
// ---------------------------------------------------------------------------
// Application wizard
// ---------------------------------------------------------------------------
interface Docs {
foreignCoc: File | null;
translation: File | null;
medical: File | null;
seamanBook: File | null;
photo: File | null;
}
function ApplicationWizard({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const [cocNo, setCocNo] = useState('');
const [issuer, setIssuer] = useState('');
const [country, setCountry] = useState('');
const [cocType, setCocType] = useState('');
const [issueDate, setIssueDate] = useState('');
const [expiryDate, setExpiryDate] = useState('');
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
const [submitted, setSubmitted] = useState(false);
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
if (submitted) {
return (
<Stack gap="lg" align="center" py="xl">
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
<Title order={3} ta="center">Application Submitted</Title>
<Text c="dimmed" ta="center" maw={400}>
Your endorsement application has been submitted. EMA officers will verify your documents
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
</Text>
<Button onClick={onDone}>Back to Endorsements</Button>
</Stack>
);
}
return (
<Stack gap="lg">
<Stepper active={step} size="sm">
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
<Stepper.Step label="Upload Documents" description="Required documents" />
<Stepper.Step label="Payment" description="Pay endorsement fee" />
<Stepper.Step label="Review & Submit" description="Final check" />
</Stepper>
{/* Step 0 — Foreign CoC details */}
{step === 0 && (
<Paper withBorder radius="lg" p="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
<Text fz="sm">
<strong>STCW Regulation I/10</strong> EMA will endorse your foreign CoC so it is
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
for the same period as your foreign CoC.
</Text>
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
</SimpleGrid>
</Paper>
)}
{/* Step 1 — Documents */}
{step === 1 && (
<Stack gap="md">
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
<Text fz="sm">
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
All documents must be clear, legible, and complete.
</Text>
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Required Documents</Text>
<Stack gap="md">
{[
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
].map((slot) => (
<FileInput
key={slot.key}
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
placeholder="Click to upload"
leftSection={<IconUpload size={14} />}
value={docs[slot.key]}
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
accept=".pdf,.jpg,.jpeg,.png"
clearable
/>
))}
</Stack>
</Paper>
{/* Upload checklist */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
<Stack gap={4}>
{[
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
{ label: 'Medical Cert', done: !!docs.medical },
{ label: 'Seaman Book', done: !!docs.seamanBook },
{ label: 'Photo', done: !!docs.photo },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
</ThemeIcon>
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
{/* Step 2 — Payment */}
{step === 2 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Endorsement Fee</Text>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb="xs">
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={600}>ETB {amount}</Text>
</Group>
))}
<Divider my="xs" />
<Group justify="space-between">
<Text fw={800}>Total</Text>
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
</Group>
</Paper>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
<Text fz="sm">
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
</Text>
</Alert>
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
</Paper>
)}
{/* Step 3 — Review */}
{step === 3 && (
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="lg">Review Your Application</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
{[
['CoC Number', cocNo],
['Country', country],
['Issuer', issuer],
['CoC Type', cocType],
['Issue Date', issueDate],
['Expiry Date', expiryDate],
].map(([label, value]) => (
<div key={label}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
))}
</SimpleGrid>
<Divider mb="md" />
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
<List spacing="xs" size="sm">
{[
{ label: 'Foreign CoC', file: docs.foreignCoc },
{ label: 'Medical Certificate', file: docs.medical },
{ label: 'Seaman Book', file: docs.seamanBook },
{ label: 'Photo', file: docs.photo },
{ label: 'Translation', file: docs.translation },
].map(({ label, file }) => file && (
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
</List.Item>
))}
</List>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
<Text fz="xs">
By submitting you confirm that all information is accurate and the documents are genuine.
Providing false information is an offence under the Maritime Code.
</Text>
</Alert>
</Paper>
)}
{/* Navigation */}
<Group justify="space-between" mt="md">
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
Back
</Button>
{step < 3 ? (
<Button
rightSection={<IconArrowRight size={14} />}
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
onClick={() => setStep(s => s + 1)}
>
Next
</Button>
) : (
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
Submit Application
</Button>
)}
</Group>
</Stack>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function EndorsementPage() {
const navigate = useNavigate();
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
if (applying) {
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
<div>
<Title order={3}>Apply for Endorsement</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag State Endorsement of Foreign CoC</Text>
</div>
</Group>
<ApplicationWizard onDone={() => setApplying(false)} />
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Endorsements</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag-state endorsement of foreign-issued Certificates of Competency</Text>
</div>
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
Apply for Endorsement
</Button>
</Group>
{/* Info panel */}
<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"><IconRubberStamp size={24} /></ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} fz="sm">What is an Endorsement?</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
{[
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 1015 working days after all documents are verified.' },
].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 Endorsement Applications</Text>
{MOCK_ENDORSEMENTS.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
) : (
<Stack gap="sm">
{MOCK_ENDORSEMENTS.map((app) => (
<Paper key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fz="sm" fw={700}>{app.cocType}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{app.statusNote}</Text>
</Alert>
</Paper>
))}
</Stack>
)}
</Paper>
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{MOCK_ISSUED.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_ISSUED.map((end) => (
<Card key={end.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{end.cocType}</Text>
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
</div>
</Group>
<Badge color={end.statusColor} variant="light">{end.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs" mb="sm">
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
</SimpleGrid>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewId}
onClose={() => setPreviewId(null)}
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
size="xl"
radius="lg"
>
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
</Modal>
</Stack>
);
}

View File

@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
interface LocationPickerProps {
value?: string;
onChange: (locationId: string | null) => void;
onChange?: (locationId: string | null) => void;
onChainChange?: (chain: Location[]) => void;
required?: boolean;
}
export function LocationPicker({ value, onChange, required }: LocationPickerProps) {
export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
const { t } = useTranslation();
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
@@ -54,7 +55,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
current = current.parentId ? locMap.get(current.parentId) : undefined;
}
setSelectedChain(chain);
}, [value, locMap]);
onChainChange?.(chain);
}, [value, locMap, onChainChange]);
const currentLevelChildren = useMemo(() => {
const parentId =
@@ -89,7 +91,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
if (!id) {
const newChain = selectedChain.slice(0, -1);
setSelectedChain(newChain);
onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChange?.(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChainChange?.(newChain);
return;
}
@@ -100,9 +103,10 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
newChain.push(loc);
setSelectedChain(newChain);
onChange(id);
onChange?.(id);
onChainChange?.(newChain);
},
[selectedChain, locMap, onChange, depth],
[selectedChain, locMap, onChange, onChainChange, depth],
);
const buildOptions = (levelIdx: number) => {

View File

@@ -0,0 +1,335 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
FileButton,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Timeline,
Title,
rem,
} 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';
// ---------------------------------------------------------------------------
// Mock current certificate — replace with real API data
// ---------------------------------------------------------------------------
const MOCK_CURRENT: MedicalCert | null = {
id: 'MC-2024-001',
issuedBy: 'EMA Approved Medical Center — Addis Ababa',
issuedDate: '2024-03-15',
expiryDate: '2026-03-14',
status: 'Expiring',
restrictions: 'None',
fileName: 'medical_cert_2024.pdf',
};
const MOCK_HISTORY: MedicalCert[] = [
{ id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' },
{ id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' },
];
interface MedicalCert {
id: string;
issuedBy: string;
issuedDate: string;
expiryDate: string;
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending';
restrictions: string;
fileName: string;
}
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() {
const [current] = useState<MedicalCert | null>(MOCK_CURRENT);
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);
const days = current ? 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 */}
{MOCK_HISTORY.length > 0 && (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Certificate History</Text>
<Stack gap="xs">
{MOCK_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>
);
}

View File

@@ -0,0 +1,331 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconAlertTriangle,
IconBell,
IconBellOff,
IconBook2,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconShield,
IconTrash,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data
// ---------------------------------------------------------------------------
type NotifType = 'warning' | 'info' | 'success' | 'error';
type NotifCategory = 'Medical' | 'Seaman Book' | 'BST' | 'Certificate' | 'Application' | 'System';
interface Notification {
id: string;
type: NotifType;
category: NotifCategory;
title: string;
message: string;
date: string;
read: boolean;
actionLabel?: string;
actionRoute?: string;
}
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: '1',
type: 'warning',
category: 'Medical',
title: 'Medical Certificate Expiring Soon',
message: 'Your medical certificate expires on 14 March 2026 — 45 days remaining. Visit an EMA-approved medical centre to renew before it lapses.',
date: '2026-01-28',
read: false,
actionLabel: 'View Medical Certificate',
actionRoute: '/medical-certificate',
},
{
id: '2',
type: 'info',
category: 'BST',
title: 'Basic Safety Training Incomplete',
message: '3 of your 5 Basic Safety Training certificates are missing (EFA, PSSR, SHPT). All 5 are required before you can apply for a Seaman Book.',
date: '2026-01-25',
read: false,
actionLabel: 'Manage BST Certificates',
actionRoute: '/basic-safety-training',
},
{
id: '3',
type: 'success',
category: 'Application',
title: 'Seaman Book Application Under Review',
message: 'Your Seaman Book application (SB-APP-2024-001) has been received and is currently under review by an EMA Registration Officer.',
date: '2024-05-10',
read: true,
actionLabel: 'Track Application',
actionRoute: '/seaman-book',
},
{
id: '4',
type: 'success',
category: 'BST',
title: 'PST Certificate Verified',
message: 'Your Personal Survival Techniques (PST) certificate has been verified and approved. Certificate No: PST-2023-BMS-0421.',
date: '2023-04-15',
read: true,
},
{
id: '5',
type: 'success',
category: 'BST',
title: 'FPFF Certificate Verified',
message: 'Your Fire Prevention & Fire Fighting (FPFF) certificate has been verified and approved. Certificate No: FPFF-2023-BMS-0421.',
date: '2023-04-15',
read: true,
},
{
id: '6',
type: 'info',
category: 'System',
title: 'Profile Setup Incomplete',
message: 'Your seafarer profile is 60% complete. Please upload your National ID, passport size photo and remaining documents to complete your registration.',
date: '2024-01-05',
read: true,
actionLabel: 'Go to Document Vault',
actionRoute: '/documents',
},
];
const TYPE_CONFIG: Record<NotifType, { color: string; icon: typeof IconBell }> = {
warning: { color: 'orange', icon: IconAlertTriangle },
info: { color: 'blue', icon: IconInfoCircle },
success: { color: 'teal', icon: IconCircleCheck },
error: { color: 'red', icon: IconAlertCircle },
};
const CATEGORY_ICON: Record<NotifCategory, typeof IconBell> = {
Medical: IconHeart,
'Seaman Book': IconBook2,
BST: IconShield,
Certificate: IconFileDescription,
Application: IconFileDescription,
System: IconBell,
};
const CATEGORIES = ['All', 'Medical', 'Seaman Book', 'BST', 'Certificate', 'Application', 'System'] as const;
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
const [filter, setFilter] = useState<string>('All');
const [readFilter, setReadFilter] = useState<string | null>(null);
const unreadCount = notifications.filter((n) => !n.read).length;
const filtered = notifications.filter((n) => {
const matchCat = filter === 'All' || n.category === filter;
const matchRead =
!readFilter ||
(readFilter === 'Unread' && !n.read) ||
(readFilter === 'Read' && n.read);
return matchCat && matchRead;
});
const markRead = (id: string) => {
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
};
const markAllRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
notify.success('All notifications marked as read.');
};
const deleteNotif = (id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
notify.info('Notification removed.');
};
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Group gap="xs">
<Title order={3}>Notifications</Title>
{unreadCount > 0 && (
<Badge color="red" variant="filled" circle size="lg">{unreadCount}</Badge>
)}
</Group>
<Text fz="sm" c="dimmed">Stay up to date on your certificates, applications and deadlines</Text>
</div>
{unreadCount > 0 && (
<Button size="sm" variant="light" leftSection={<IconCheck size={14} />} onClick={markAllRead}>
Mark all as read
</Button>
)}
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'All', count: notifications.length, color: 'gray', icon: IconBell },
{ label: 'Unread', count: notifications.filter((n) => !n.read).length, color: 'blue', icon: IconBell },
{ label: 'Warnings', count: notifications.filter((n) => n.type === 'warning').length, color: 'orange', icon: IconAlertTriangle },
{ label: 'Actions Needed', count: notifications.filter((n) => !n.read && n.type !== 'success').length, color: 'red', icon: IconAlertCircle },
].map(({ label, count, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={36} radius="md">
<Icon size={17} />
</ThemeIcon>
<div>
<Text fz="lg" fw={700} lh={1}>{count}</Text>
<Text fz="xs" c="dimmed">{label}</Text>
</div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Filters */}
<Group gap="sm" wrap="wrap">
<Group gap={6} style={{ flex: 1 }}>
{CATEGORIES.map((cat) => (
<Button
key={cat}
size="xs"
variant={filter === cat ? 'filled' : 'light'}
color="blue"
onClick={() => setFilter(cat)}
>
{cat}
</Button>
))}
</Group>
<Select
size="xs"
placeholder="All Status"
data={['Unread', 'Read']}
value={readFilter}
onChange={setReadFilter}
clearable
style={{ width: rem(120) }}
/>
</Group>
{/* Notification list */}
{filtered.length === 0 ? (
<Paper withBorder radius="md" p="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
<IconBellOff size={22} />
</ThemeIcon>
<Text fz="sm" c="dimmed">No notifications found</Text>
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setFilter('All'); setReadFilter(null); }}>
Clear filters
</Button>
</Paper>
) : (
<Stack gap="xs">
{filtered.map((n) => {
const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
const CatIcon = CATEGORY_ICON[n.category];
return (
<Card
key={n.id}
withBorder
radius="md"
p="md"
style={{
borderLeft: `3px solid var(--mantine-color-${color}-5)`,
background: n.read ? undefined : 'var(--mantine-color-blue-light)',
opacity: n.read ? 0.85 : 1,
}}
onClick={() => markRead(n.id)}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ flex: 1 }}>
<ThemeIcon variant="light" color={color} size={38} radius="md" style={{ flexShrink: 0, marginTop: 2 }}>
<TypeIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Text fw={n.read ? 500 : 700} fz="sm">{n.title}</Text>
{!n.read && <Badge size="xs" color="blue" variant="filled">New</Badge>}
<Badge size="xs" variant="light" color="gray" leftSection={<CatIcon size={10} />}>
{n.category}
</Badge>
</Group>
<Text fz="sm" c="dimmed" lh={1.5}>{n.message}</Text>
<Group gap="sm" mt={4}>
<Text fz="xs" c="dimmed">{formatDate(n.date)}</Text>
{n.actionLabel && (
<Button
size="xs"
variant="light"
color={color}
component="a"
href={n.actionRoute}
onClick={(e) => e.stopPropagation()}
>
{n.actionLabel}
</Button>
)}
</Group>
</Stack>
</Group>
<Group gap="xs" style={{ flexShrink: 0 }}>
{!n.read && (
<ActionIcon
variant="subtle"
color="teal"
size="sm"
title="Mark as read"
onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
>
<IconCheck size={14} />
</ActionIcon>
)}
<ActionIcon
variant="subtle"
color="red"
size="sm"
title="Delete"
onClick={(e) => { e.stopPropagation(); deleteNotif(n.id); }}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
);
})}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,370 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Center,
Group,
Paper,
Stack,
Text,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconLogout2,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../../profile/components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../../profile/components/AddressFormContent';
const STEPS = [
{ label: 'Profile', icon: IconUser },
{ label: 'Address', icon: IconMapPin },
];
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
export function ProfileSetupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
const [meTrigger] = useApiMutation<AuthUser>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
);
const professionNameMap = useMemo(() => {
const map: Record<string, string> = {};
professions.forEach((p) => {
map[p.id] = p.name.en;
});
return map;
}, [professions]);
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
const profileDefaults: ProfileValues = useMemo(() => ({
professionId: '',
firstName: nameParts[0] || '',
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
gender: '',
dob: '',
pob: '',
maritalStatus: '',
}), [nameParts]);
const addressDefaults: AddressValues = useMemo(() => ({
idType: '',
idNumber: '',
nationality: '',
primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: '',
email: user?.email || '',
regionId: '',
cityId: '',
subcityId: '',
woredaId: '',
kebeleId: '',
streetAddress: '',
postalAddress: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
}), [user]);
const {
register: profileRegister,
handleSubmit: profileHandleSubmit,
formState: { errors: profileErrors },
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: profileDefaults,
});
const {
register: addressRegister,
handleSubmit: addressHandleSubmit,
formState: { errors: addressErrors },
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
defaultValues: addressDefaults,
});
const onNext = async () => {
const valid = await profileTriggerValidation();
if (!valid) return;
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
setActive((c) => c + 1);
};
const onSubmitAddress = async () => {
const valid = await addressTriggerValidation();
if (!valid) return;
setSubmitting(true);
try {
const pv = profileWatch();
const av = addressWatch();
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: 'SEAFARER',
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: av.idType,
idNumber: av.idNumber,
nationality: av.nationality,
primaryPhoneNumber: av.primaryPhoneNumber,
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
email: av.email || undefined,
regionId: av.regionId || undefined,
cityId: av.cityId || undefined,
subcityId: av.subcityId || undefined,
woredaId: av.woredaId || undefined,
kebeleId: av.kebeleId || undefined,
streetAddress: av.streetAddress || undefined,
postalAddess: av.postalAddress || undefined,
emergencyContactName: av.emergencyContactName || undefined,
emergencyContactPhone: av.emergencyContactPhone || undefined,
emergencyContactRelation: av.emergencyContactRelation || undefined,
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
} catch {
notify.error('Failed to save profile. Please try again.');
} finally {
setSubmitting(false);
}
};
if (!user) {
return (
<Center mih="100vh">
<Text c="dimmed">Please log in first.</Text>
</Center>
);
}
return (
<Center mih="100vh" bg="gray.0">
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
<Stack gap="md">
<div>
<Title order={3}>Complete Your Profile</Title>
<Text fz="sm" c="dimmed">
Set up your profile and address to get started
</Text>
</div>
<StepIndicator active={active} completed={completed} />
{active === 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<ProfileFormContent
register={profileRegister}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</>
)}
{active === 1 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<AddressFormContent
register={addressRegister}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
color="gray"
leftSection={<IconLogout2 size={16} />}
onClick={() => {
dispatch(logout());
navigate('/login');
}}
>
Sign out
</Button>
<Group gap="sm">
{active > 0 && (
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActive((c) => c - 1)}
>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={onSubmitAddress}
loading={submitting}
>
Complete Setup
</Button>
)}
</Group>
</Group>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,177 @@
import { useCallback, useMemo } from 'react';
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location } from '../../location/types/location';
export const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
idNumber: z.string().min(1, 'Enter ID number'),
nationality: z.string().min(1, 'Enter nationality'),
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
secondaryPhoneNumber: z.string().optional(),
email: z.string().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subcityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().optional(),
postalAddress: z.string().optional(),
emergencyContactName: z.string().optional(),
emergencyContactPhone: z.string().optional(),
emergencyContactRelation: z.string().optional(),
});
export type AddressValues = z.infer<typeof addressSchema>;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
const LEVEL_TO_FIELD: Record<number, keyof AddressValues> = {
1: 'cityId',
2: 'subcityId',
3: 'woredaId',
4: 'kebeleId',
};
interface AddressFormContentProps {
register: UseFormRegister<AddressValues>;
errors: FieldErrors<AddressValues>;
setValue: UseFormSetValue<AddressValues>;
watch: UseFormWatch<AddressValues>;
trigger: UseFormTrigger<AddressValues>;
}
export function AddressFormContent({
register,
errors,
setValue,
watch,
trigger,
}: AddressFormContentProps) {
const { data: typesRes } = useGetLocationTypesQuery();
const locationTypes = typesRes?.items ?? [];
const typeLevelMap = useMemo(() => {
const map = new Map<string, number>();
locationTypes.forEach((lt) => map.set(lt.id, lt.level));
return map;
}, [locationTypes]);
const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
const handleChainChange = useCallback(
(chain: Location[]) => {
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
return;
}
setValue('cityId', '');
setValue('subcityId', '');
setValue('woredaId', '');
setValue('kebeleId', '');
chain.forEach((loc) => {
const level = typeLevelMap.get(loc.locationTypeId);
if (level && LEVEL_TO_FIELD[level]) {
setValue(LEVEL_TO_FIELD[level], loc.id);
}
});
},
[setValue, typeLevelMap],
);
return (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="ID Type"
placeholder="Select"
required
data={[...ID_TYPES]}
error={errors.idType?.message}
value={watch('idType')}
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
onBlur={() => trigger('idType')}
name="idType"
/>
<TextInput
label="ID Number"
placeholder="Enter ID number"
required
{...register('idNumber')}
error={errors.idNumber?.message}
/>
<TextInput
label="Nationality"
placeholder="e.g. Ethiopian"
required
{...register('nationality')}
error={errors.nationality?.message}
/>
<TextInput
label="Primary Phone"
placeholder="+251 9XX XXX XXX"
required
{...register('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<TextInput
label="Secondary Phone"
placeholder="+251 9XX XXX XXX"
{...register('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
label="Email"
type="email"
placeholder="email@example.com"
{...register('email')}
error={errors.email?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<LocationPicker
value={leafId}
onChainChange={handleChainChange}
/>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput
label="Street Address"
placeholder="Street name, house number"
{...register('streetAddress')}
error={errors.streetAddress?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Contact Name"
placeholder="Full name"
{...register('emergencyContactName')}
error={errors.emergencyContactName?.message}
/>
<TextInput
label="Contact Phone"
placeholder="+251 9XX XXX XXX"
{...register('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput
label="Relationship"
placeholder="Spouse, Parent, etc."
{...register('emergencyContactRelation')}
error={errors.emergencyContactRelation?.message}
/>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,115 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
export const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'),
firstName: z.string().min(3, 'First name must be at least 3 characters'),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
gender: z.string().min(1, 'Select your gender'),
dob: z.string().min(1, 'Select your date of birth'),
pob: z.string().optional(),
maritalStatus: z.string().min(1, 'Select your marital status'),
});
export type ProfileValues = z.infer<typeof profileSchema>;
export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
interface ProfileFormContentProps {
register: UseFormRegister<ProfileValues>;
errors: FieldErrors<ProfileValues>;
setValue: UseFormSetValue<ProfileValues>;
watch: UseFormWatch<ProfileValues>;
trigger: UseFormTrigger<ProfileValues>;
professionsLoading: boolean;
professionOptions: Array<{ value: string; label: string }>;
}
export function ProfileFormContent({
register,
errors,
setValue,
watch,
trigger,
professionsLoading,
professionOptions,
}: ProfileFormContentProps) {
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="Profession"
placeholder={professionsLoading ? 'Loading...' : 'Select'}
required
data={professionOptions}
error={errors.professionId?.message}
value={watch('professionId')}
onChange={(val) => setValue('professionId', val || '', { shouldValidate: true })}
onBlur={() => trigger('professionId')}
name="professionId"
searchable
disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
/>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...register('firstName')}
error={errors.firstName?.message}
/>
<TextInput
label="Middle Name"
placeholder="Enter middle name"
required
{...register('middleName')}
error={errors.middleName?.message}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...register('lastName')}
error={errors.lastName?.message}
/>
<Select
label="Gender"
placeholder="Select"
required
data={[...GENDERS]}
error={errors.gender?.message}
value={watch('gender')}
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
onBlur={() => trigger('gender')}
name="gender"
/>
<TextInput
label="Date of Birth"
type="date"
required
{...register('dob')}
error={errors.dob?.message}
/>
<TextInput
label="Place of Birth"
placeholder="City, Region"
{...register('pob')}
error={errors.pob?.message}
/>
<Select
label="Marital Status"
placeholder="Select"
required
data={[...MARITAL_STATUSES]}
error={errors.maritalStatus?.message}
value={watch('maritalStatus')}
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}
onBlur={() => trigger('maritalStatus')}
name="maritalStatus"
/>
</SimpleGrid>
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Box,
Button,
Center,
Divider,
Group,
Loader,
Paper,
PasswordInput,
SimpleGrid,
@@ -28,6 +30,7 @@ import {
IconDeviceFloppy,
IconLock,
IconMail,
IconMapPin,
IconMoon,
IconPhone,
IconSettings,
@@ -42,10 +45,21 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
import type { CurrentProfile } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../components/ProfileFormContent';
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../components/AddressFormContent';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -56,7 +70,6 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase();
}
/** 04 rough strength score used by the meter on the security tab. */
function passwordScore(pw: string) {
if (!pw) return 0;
let score = 0;
@@ -71,21 +84,121 @@ export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
const [passwordTrigger] = useApiMutation<unknown>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
const [isSavingAddress, setIsSavingAddress] = useState(false);
// UI-only preferences (no backend wiring yet).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
const [emailNotifications, setEmailNotifications] = useState(true);
// Load the latest profile from the server on mount so the form always
// reflects the current account information (the cached user may be stale).
// ---- Profession list (for Profile tab) ----
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const professionsFetched = useRef(false);
useEffect(() => {
if (professionsFetched.current) return;
professionsFetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
);
const professionNameMap = useMemo(() => {
const map: Record<string, string> = {};
professions.forEach((p) => { map[p.id] = p.name.en; });
return map;
}, [professions]);
// ---- Profile data (from stored currentProfile) ----
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [updateProfile] = useApiMutation<unknown>();
const [updateAddress] = useApiMutation<unknown>();
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
const [profileId, setProfileId] = useState<string | null>(null);
const [addressId, setAddressId] = useState<string | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const profileFetched = useRef(false);
useEffect(() => {
if (currentProfile) {
setProfileId(currentProfile.id);
setLoadedProfile({
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
firstName: currentProfile.firstName || '',
middleName: currentProfile.middleName || '',
lastName: currentProfile.lastName || '',
gender: currentProfile.gender || '',
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
pob: currentProfile.pob || '',
maritalStatus: currentProfile.maritalStatus || '',
});
if (currentProfile.address) {
setAddressId(currentProfile.address.id);
setLoadedAddress({
idType: currentProfile.address.idType || '',
idNumber: currentProfile.address.idNumber || '',
nationality: currentProfile.address.nationality || '',
primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '',
secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '',
email: currentProfile.address.email || '',
regionId: currentProfile.address.regionId || '',
cityId: currentProfile.address.cityId || '',
subcityId: currentProfile.address.subCityId || '',
woredaId: currentProfile.address.woredaId || '',
kebeleId: currentProfile.address.kebeleId || '',
streetAddress: currentProfile.address.streetAddress || '',
postalAddress: currentProfile.address.postalAddress || '',
emergencyContactName: currentProfile.address.emergencyContactName || '',
emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
emergencyContactRelation: currentProfile.address.emergencycontactRelation || '',
});
}
setDataLoading(false);
} else if (user && !profileFetched.current) {
profileFetched.current = true;
const profileId = authStorage.getProfileId();
if (profileId) {
const q = `w=user_id:=:${user.id}&i=user,address,profession`;
fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' })
.unwrap()
.then((result) => {
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
dispatch(setCurrentProfile(profile));
} else {
setDataLoading(false);
}
})
.catch(() => setDataLoading(false));
} else {
setDataLoading(false);
}
} else {
setDataLoading(false);
}
}, [currentProfile, user, fetchProfile, dispatch]);
// Load the latest user from the server on mount
useEffect(() => {
let active = true;
meTrigger({ url: '/auth/me', method: 'GET' })
@@ -93,37 +206,28 @@ export function ProfilePage() {
.then((me) => {
if (active) dispatch(setUser(me));
})
.catch(() => {
/* fall back to the cached user already in the store */
});
return () => {
active = false;
};
// meTrigger/dispatch are stable; run once on mount.
.catch(() => {});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- Profile form ----
const profileSchema = z.object({
// ---- Personal form (auth user data) ----
const personalSchema = z.object({
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z
.string()
.min(1, { message: t('profile.validation.usernameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
});
type ProfileValues = z.infer<typeof profileSchema>;
type PersonalValues = z.infer<typeof personalSchema>;
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
register: registerPersonal,
handleSubmit: handlePersonalSubmit,
reset: resetPersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
values: {
nameEn: user?.name?.en ?? '',
nameAm: user?.name?.am ?? '',
@@ -133,7 +237,7 @@ export function ProfilePage() {
},
});
const onSaveProfile = async (values: ProfileValues) => {
const onSavePersonal = async (values: PersonalValues) => {
setIsSavingProfile(true);
try {
await updateTrigger({
@@ -147,7 +251,6 @@ export function ProfilePage() {
},
}).unwrap();
// Refresh the cached user so the rest of the app stays in sync.
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
@@ -159,18 +262,77 @@ export function ProfilePage() {
}
};
// ---- Maritime Profile form ----
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
formState: { errors: profileErrors },
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
values: loadedProfile ?? undefined,
});
const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return;
setIsSavingMaritime(true);
try {
await updateProfile({
url: `/profiles/${profileId}`,
method: 'PUT',
body: values,
}).unwrap();
notify.success('Profile updated');
} catch {
notify.error('Failed to update profile');
} finally {
setIsSavingMaritime(false);
}
};
// ---- Address form ----
const {
register: registerAddress,
handleSubmit: handleAddressSubmit,
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
formState: { errors: addressErrors },
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
values: loadedAddress ?? undefined,
});
const onSaveAddress = async (values: AddressValues) => {
if (!addressId) return;
setIsSavingAddress(true);
try {
await updateAddress({
url: `/addresss/${addressId}`,
method: 'PUT',
body: {
...values,
postalAddess: values.postalAddress,
},
}).unwrap();
notify.success('Address updated');
} catch {
notify.error('Failed to update address');
} finally {
setIsSavingAddress(false);
}
};
// ---- Password form ----
const passwordSchema = z
.object({
oldPassword: z
.string()
.min(1, { message: t('profile.validation.passwordMin') }),
newPassword: z
.string()
.min(8, { message: t('profile.validation.passwordMin') }),
confirmPassword: z
.string()
.min(8, { message: t('profile.validation.passwordMin') }),
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t('profile.validation.passwordMismatch'),
@@ -214,21 +376,13 @@ export function ProfilePage() {
const displayName = user?.name?.en || user?.username || '';
const score = passwordScore(watchPassword('newPassword'));
const strengthLabels = [
'',
t('profile.strength.weak'),
t('profile.strength.fair'),
t('profile.strength.good'),
t('profile.strength.strong'),
'', t('profile.strength.weak'), t('profile.strength.fair'),
t('profile.strength.good'), t('profile.strength.strong'),
];
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
// Mantine uses 'auto' for the system option.
const appearanceOptions: {
value: MantineColorScheme;
label: string;
icon: typeof IconSun;
}[] = [
const appearanceOptions: { value: MantineColorScheme; label: string; icon: typeof IconSun }[] = [
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun },
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
@@ -297,13 +451,19 @@ export function ProfilePage() {
{/* Tabs */}
<Tabs
defaultValue="profile"
defaultValue="personal"
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>
<Tabs.List>
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
{t('profile.tabs.profile')}
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
Personal
</Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
Profile
</Tabs.Tab>
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
@@ -313,10 +473,10 @@ export function ProfilePage() {
</Tabs.Tab>
</Tabs.List>
{/* ---- Profile ---- */}
<Tabs.Panel value="profile" pt="md">
{/* ---- Personal (auth user data) ---- */}
<Tabs.Panel value="personal" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<form onSubmit={handlePersonalSubmit(onSavePersonal)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.personal')}</Title>
@@ -327,14 +487,14 @@ export function ProfilePage() {
<TextInput
label={t('profile.fields.fullNameEn')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameEn?.message}
{...registerProfile('nameEn')}
error={personalErrors.nameEn?.message}
{...registerPersonal('nameEn')}
/>
<TextInput
label={t('profile.fields.fullNameAm')}
leftSection={<IconUser size={18} />}
error={profileErrors.nameAm?.message}
{...registerProfile('nameAm')}
error={personalErrors.nameAm?.message}
{...registerPersonal('nameAm')}
/>
<TextInput
label={t('profile.fields.username')}
@@ -342,8 +502,8 @@ export function ProfilePage() {
readOnly
variant="filled"
leftSection={<IconAt size={18} />}
error={profileErrors.username?.message}
{...registerProfile('username')}
error={personalErrors.username?.message}
{...registerPersonal('username')}
/>
</SimpleGrid>
</div>
@@ -358,14 +518,14 @@ export function ProfilePage() {
<TextInput
label={t('profile.fields.email')}
leftSection={<IconMail size={18} />}
error={profileErrors.email?.message}
{...registerProfile('email')}
error={personalErrors.email?.message}
{...registerPersonal('email')}
/>
<TextInput
label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />}
error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
error={personalErrors.phoneNumber?.message}
{...registerPersonal('phoneNumber')}
/>
</SimpleGrid>
</div>
@@ -374,7 +534,7 @@ export function ProfilePage() {
<Button
type="button"
variant="default"
onClick={() => resetProfile()}
onClick={() => resetPersonal()}
>
{t('profile.cancel')}
</Button>
@@ -391,6 +551,90 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Maritime Profile ---- */}
<Tabs.Panel value="profile" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
{dataLoading ? (
<Center py="xl"><Loader /></Center>
) : !loadedProfile ? (
<Text c="dimmed" ta="center" py="xl">
No profile found. Complete your profile setup first.
</Text>
) : (
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl">
<div>
<Title order={5}>Maritime Profile</Title>
<Text size="sm" c="dimmed" mb="md">
Your professional maritime details
</Text>
<ProfileFormContent
register={registerProfile}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</div>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Profile
</Button>
</Group>
</Stack>
</form>
)}
</Paper>
</Tabs.Panel>
{/* ---- Address ---- */}
<Tabs.Panel value="address" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
{dataLoading ? (
<Center py="xl"><Loader /></Center>
) : !loadedAddress ? (
<Text c="dimmed" ta="center" py="xl">
No address found. Complete your profile setup first.
</Text>
) : (
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
<Stack gap="xl">
<div>
<Title order={5}>Address & Contact</Title>
<Text size="sm" c="dimmed" mb="md">
Your identity documents, contact details and emergency contact
</Text>
<AddressFormContent
register={registerAddress}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</div>
<Group justify="flex-end">
<Button
type="submit"
loading={isSavingAddress}
leftSection={<IconDeviceFloppy size={18} />}
>
Save Address
</Button>
</Group>
</Stack>
</form>
)}
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -33,9 +33,8 @@ import {
IconUser,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';

View File

@@ -0,0 +1,655 @@
import { useRef, useState } from 'react';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconBook2,
IconCheck,
IconCircleCheck,
IconCreditCard,
IconHeart,
IconId,
IconInfoCircle,
IconShieldCheck,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
const STEPS = [
{ label: 'BST Certificates' },
{ label: 'Medical Certificate' },
{ label: 'Payment' },
{ label: 'Review & Submit' },
];
// ---------------------------------------------------------------------------
// BST slots
// ---------------------------------------------------------------------------
interface BSTSlot {
key: string;
label: string;
short: string;
refreshYears: number;
}
const BST_SLOTS: BSTSlot[] = [
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques (PST)', refreshYears: 5 },
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting (FPFF)', refreshYears: 5 },
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid (EFA)', refreshYears: 0 },
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility (PSSR)',refreshYears: 0 },
{ key: 'shp', short: 'SHPT', label: 'Sexual Harassment Prevention Training (SHPT)', refreshYears: 0 },
];
// ---------------------------------------------------------------------------
// Fee table — Seaman Book + BTC shown separately, paid together
// ---------------------------------------------------------------------------
const FEES = [
{ label: 'Seaman Book — Application Fee', amount: 500 },
{ label: 'Seaman Book — Document Verification Fee',amount: 200 },
{ label: 'Basic Training Certificate (BTC) — Application Fee', amount: 300 },
{ label: 'BTC — Document Verification Fee', amount: 100 },
{ label: 'BSID — Application Fee', amount: 100 },
{ label: 'BSID — Card Production Fee', amount: 150 },
];
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
// ---------------------------------------------------------------------------
// Step indicator
// ---------------------------------------------------------------------------
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box style={{
width: rem(40), height: rem(40), borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : isCurrent ? 'var(--mantine-color-blue-7)' : 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34,139,230,0.2)' : 'none',
flexShrink: 0, transition: 'all 0.2s ease',
}}>
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
)}
</Box>
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
{isDone ? `${step.label}` : step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box style={{
flex: 1, height: rem(2),
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}} />
)}
</Group>
);
})}
</Group>
</Box>
);
}
function SectionHead({ title }: { title: string }) {
return (
<>
<Divider mt="md" mb="xs" />
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
</>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// BST upload card
// ---------------------------------------------------------------------------
function BSTCard({ slot, certNumber, onCertNumber, issuer, onIssuer, issueDate, onIssueDate, expiryDate, onExpiryDate, file, onFile, resetRef }: {
slot: BSTSlot;
certNumber: string; onCertNumber: (v: string) => void;
issuer: string; onIssuer: (v: string) => void;
issueDate: string; onIssueDate: (v: string) => void;
expiryDate: string; onExpiryDate: (v: string) => void;
file: File | null; onFile: (f: File | null) => void;
resetRef: React.MutableRefObject<(() => void) | null>;
}) {
const isComplete = !!file && !!certNumber.trim() && !!issuer.trim() && !!issueDate;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: isComplete ? 'solid' : 'dashed',
borderColor: isComplete ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
background: isComplete ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconShieldCheck size={20} color={isComplete ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div style={{ flex: 1, minWidth: 0 }}>
<Group gap={4}>
<Text fw={600} fz="sm">{slot.short}</Text>
<Text span c="red" fz="xs">*</Text>
{isComplete && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
<Text fz="xs" c="dimmed" lh={1.3}>{slot.label}</Text>
</div>
</Group>
<Stack gap="xs">
<TextInput label="Certificate Number" placeholder="e.g. PST-2024-001" size="xs" value={certNumber} onChange={(e) => onCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Institution" placeholder="e.g. Bahirdar Maritime School" size="xs" value={issuer} onChange={(e) => onIssuer(e.currentTarget.value)} />
<SimpleGrid cols={slot.refreshYears > 0 ? 2 : 1} spacing="xs">
<TextInput label="Issue Date" type="date" size="xs" value={issueDate} onChange={(e) => onIssueDate(e.currentTarget.value)} />
{slot.refreshYears > 0 && (
<TextInput label={`Expiry (${slot.refreshYears}yr)`} type="date" size="xs" value={expiryDate} onChange={(e) => onExpiryDate(e.currentTarget.value)} />
)}
</SimpleGrid>
{file ? (
<Group gap="xs" align="center" mt={4}>
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>
<IconTrash size={12} />
</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="xs" variant="light" leftSection={<IconUpload size={12} />} fullWidth mt={4} {...props}>
Upload Certificate
</Button>
)}
</FileButton>
)}
</Stack>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function SeamanBookApplicationPage() {
const navigate = useNavigate();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// BST
const [bstData, setBstData] = useState<Record<string, { certNumber: string; issuer: string; issueDate: string; expiryDate: string; file: File | null }>>(() =>
Object.fromEntries(BST_SLOTS.map((s) => [s.key, { certNumber: '', issuer: '', issueDate: '', expiryDate: '', file: null }]))
);
const bstResetRefs = useRef<Record<string, (() => void) | null>>({});
const updateBst = (key: string, field: string, value: string | File | null) =>
setBstData((prev) => ({ ...prev, [key]: { ...prev[key], [field]: value } }));
// Medical
const [medCertNumber, setMedCertNumber] = useState('');
const [medIssuer, setMedIssuer] = useState('');
const [medIssueDate, setMedIssueDate] = useState('');
const [medExpiryDate, setMedExpiryDate] = useState('');
const [medFile, setMedFile] = useState<File | null>(null);
const medResetRef = useRef<() => void>(null);
// Payment
const [paymentMethod, setPaymentMethod] = useState<'cbe' | 'telebirr' | null>(null);
const [paymentRef, setPaymentRef] = useState('');
const [paymentDate, setPaymentDate] = useState('');
const [paymentFile, setPaymentFile] = useState<File | null>(null);
const payResetRef = useRef<() => void>(null);
// Validation
const bstComplete = BST_SLOTS.every((s) => {
const d = bstData[s.key];
return !!d.file && !!d.certNumber.trim() && !!d.issuer.trim() && !!d.issueDate;
});
const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate;
const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate;
const canNext = () => {
if (active === 0) return bstComplete;
if (active === 1) return medComplete;
if (active === 2) return payComplete;
return true;
};
const next = () => {
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
setActive((c) => c + 1);
};
const prev = () => setActive((c) => c - 1);
const handleSubmit = async () => {
setSubmitting(true);
try {
await new Promise((r) => setTimeout(r, 1400));
notify.success('Application submitted! Reference: SB-BTC-2025-001');
navigate('/seaman-book');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Title order={3}>Seaman Book, BTC & BSID Application</Title>
<Text fz="sm" c="dimmed">
One application covers your Seaman Book, Basic Training Certificate (BTC), and BSID Step {active + 1} of {STEPS.length}
</Text>
</div>
{/* What you will receive banner */}
<Paper withBorder radius="md" p="md" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-2)' }}>
<Group gap="lg" wrap="wrap">
<Group gap="xs">
<IconBook2 size={18} color="var(--mantine-color-blue-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="blue.8">Seaman Book</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="teal.8">Basic Training Certificate (BTC)</Text>
</Group>
<Text fz="sm" c="dimmed">+</Text>
<Group gap="xs">
<IconId size={18} color="var(--mantine-color-violet-6)" stroke={1.5} />
<Text fz="sm" fw={600} c="violet.8">BSID (Biometric Seafarer ID)</Text>
</Group>
</Group>
</Paper>
<StepIndicator active={active} completed={completed} />
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Text fw={700} fz="lg">{STEPS[active]?.label}</Text>
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
</Group>
{/* ── Step 1: BST ─────────────────────────────────────────────── */}
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload all 5 Basic Safety Training certificates. These training certificates issued by approved institutions are different from the EMA-issued BTC they are the prerequisite for your BTC.
</Alert>
<SectionHead title="5 Mandatory BST Training Certificates" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
const rRef = { current: bstResetRefs.current[slot.key] ?? null };
return (
<BSTCard
key={slot.key}
slot={slot}
certNumber={d.certNumber}
onCertNumber={(v) => updateBst(slot.key, 'certNumber', v)}
issuer={d.issuer}
onIssuer={(v) => updateBst(slot.key, 'issuer', v)}
issueDate={d.issueDate}
onIssueDate={(v) => updateBst(slot.key, 'issueDate', v)}
expiryDate={d.expiryDate}
onExpiryDate={(v) => updateBst(slot.key, 'expiryDate', v)}
file={d.file}
onFile={(f) => updateBst(slot.key, 'file', f)}
resetRef={rRef}
/>
);
})}
</SimpleGrid>
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={600} fz="sm" mb="sm">Upload Progress</Text>
<SimpleGrid cols={{ base: 2, sm: 5 }} spacing="sm">
{BST_SLOTS.map((slot) => {
const done = !!bstData[slot.key].file && !!bstData[slot.key].certNumber;
return (
<Group key={slot.key} gap={6} align="center">
{done ? <IconCircleCheck size={15} color="var(--mantine-color-teal-6)" /> : (
<Box style={{ width: 15, height: 15, borderRadius: '50%', border: '2px solid var(--mantine-color-gray-3)' }} />
)}
<Text fz="xs" c={done ? 'teal.7' : 'dimmed'} fw={done ? 600 : 400}>{slot.short}</Text>
</Group>
);
})}
</SimpleGrid>
</Paper>
</Stack>
)}
{/* ── Step 2: Medical ─────────────────────────────────────────── */}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Upload your valid medical certificate from an EMA-approved medical centre. Required for both Seaman Book and BTC issuance.
</Alert>
<SectionHead title="Medical Certificate Details" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Certificate Number" placeholder="e.g. MC-2024-001" required value={medCertNumber} onChange={(e) => setMedCertNumber(e.currentTarget.value)} />
<TextInput label="Issuing Medical Centre" placeholder="e.g. EMA Medical Centre" required value={medIssuer} onChange={(e) => setMedIssuer(e.currentTarget.value)} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Issue Date" type="date" required value={medIssueDate} onChange={(e) => setMedIssueDate(e.currentTarget.value)} />
<TextInput label="Expiry Date" type="date" required value={medExpiryDate} onChange={(e) => setMedExpiryDate(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Upload Certificate" />
<Card withBorder radius="md" p="md" style={{
borderStyle: medFile ? 'solid' : 'dashed',
borderColor: medFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: medFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconHeart size={20} color={medFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">Medical Certificate <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{medFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{medFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setMedFile(null); medResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={medResetRef} onChange={setMedFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Choose File
</Button>
)}
</FileButton>
)}
</Card>
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={17} />}>
Only certificates from <strong>EMA-approved medical centres</strong> are accepted.
</Alert>
</Stack>
)}
{/* ── Step 3: Payment ─────────────────────────────────────────── */}
{active === 2 && (
<Stack gap="md">
{/* Fee breakdown — SB + BTC shown separately */}
<Paper withBorder radius="md" p="md" bg="gray.0">
<Text fw={700} fz="sm" mb="xs">Fee Breakdown</Text>
<Text fz="xs" c="dimmed" mb="md">Your payment covers both the Seaman Book and Basic Training Certificate (BTC).</Text>
{/* SB fees */}
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>Seaman Book</Text>
{FEES.filter(f => f.label.startsWith('Seaman Book')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Seaman Book — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BTC fees */}
<Text fz="xs" fw={700} tt="uppercase" c="teal.7" mb={6}>Basic Training Certificate (BTC)</Text>
{FEES.filter(f => f.label.startsWith('Basic Training') || f.label.startsWith('BTC')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('Basic Training Certificate (BTC) — ', '').replace('BTC — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider my="xs" />
{/* BSID fees */}
<Divider my="xs" />
<Text fz="xs" fw={700} tt="uppercase" c="violet.7" mb={6}>BSID (Biometric Seafarer ID)</Text>
{FEES.filter(f => f.label.startsWith('BSID')).map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label.replace('BSID — ', '')}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="xs" mb="sm" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total Amount Due</Text>
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
</Group>
</Paper>
<SectionHead title="Select Payment Method" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" style={{ maxWidth: rem(600) }}>
{/* CBE */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('cbe'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'cbe' ? 2 : 1,
background: paymentMethod === 'cbe' ? 'var(--mantine-color-blue-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">CBE Bank Transfer</Text>
<Text fz="xs" c="dimmed">Commercial Bank of Ethiopia</Text>
</div>
{paymentMethod === 'cbe' && <IconCircleCheck size={18} color="var(--mantine-color-blue-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
{/* Telebirr */}
<Card withBorder radius="md" p="md" onClick={() => { setPaymentMethod('telebirr'); setPaymentRef(''); }}
style={{
cursor: 'pointer',
borderColor: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-6)' : 'var(--mantine-color-default-border)',
borderWidth: paymentMethod === 'telebirr' ? 2 : 1,
background: paymentMethod === 'telebirr' ? 'var(--mantine-color-violet-light)' : undefined,
}}>
<Group gap="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8), flexShrink: 0,
background: 'var(--mantine-color-violet-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconCreditCard size={22} color="var(--mantine-color-violet-6)" stroke={1.5} />
</Box>
<div>
<Text fw={700} fz="sm">Telebirr</Text>
<Text fz="xs" c="dimmed">Ethio Telecom Mobile Money</Text>
</div>
{paymentMethod === 'telebirr' && <IconCircleCheck size={18} color="var(--mantine-color-violet-6)" style={{ marginLeft: 'auto' }} />}
</Group>
</Card>
</SimpleGrid>
{paymentMethod === 'cbe' && (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
Transfer <strong>ETB {TOTAL.toFixed(2)}</strong> to CBE Account <strong>1000123456789</strong> (EMA Maritime Authority). Use your full name as description.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="CBE Transaction Reference" placeholder="e.g. CBE-TXN-20240510-001" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod === 'telebirr' && (
<>
<Alert variant="light" color="violet" icon={<IconInfoCircle size={15} />}>
Send <strong>ETB {TOTAL.toFixed(2)}</strong> to Telebirr <strong>+251 11 551 0000</strong> (EMA Maritime Authority). Screenshot and upload your confirmation.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="Telebirr Transaction ID" placeholder="e.g. TLB-2024-001234" required value={paymentRef} onChange={(e) => setPaymentRef(e.currentTarget.value)} />
<TextInput label="Payment Date" type="date" required value={paymentDate} onChange={(e) => setPaymentDate(e.currentTarget.value)} />
</SimpleGrid>
</>
)}
{paymentMethod && (
<>
<SectionHead title="Upload Receipt (optional)" />
<Card withBorder radius="md" p="md" style={{
borderStyle: paymentFile ? 'solid' : 'dashed',
borderColor: paymentFile ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
maxWidth: rem(420),
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(40), height: rem(40), borderRadius: rem(8),
background: paymentFile ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconCreditCard size={20} color={paymentFile ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{paymentMethod === 'telebirr' ? 'Telebirr Screenshot' : 'Bank Receipt'}</Text>
<Text fz="xs" c="dimmed">PDF, JPG or PNG max 5MB</Text>
</div>
</Group>
{paymentFile ? (
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{paymentFile.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => { setPaymentFile(null); payResetRef.current?.(); }}>
<IconTrash size={13} />
</Button>
</Group>
) : (
<FileButton resetRef={payResetRef} onChange={setPaymentFile} accept="application/pdf,image/jpeg,image/png">
{(props) => (
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
Upload Receipt
</Button>
)}
</FileButton>
)}
</Card>
</>
)}
</Stack>
)}
{/* ── Step 4: Review ──────────────────────────────────────────── */}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Submitting this application will initiate processing for both your <strong>Seaman Book</strong> and <strong>Basic Training Certificate (BTC)</strong>.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">BST Certificates</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{BST_SLOTS.map((slot) => {
const d = bstData[slot.key];
return (
<div key={slot.key}>
<Group gap="xs" mb={4}>
<IconCircleCheck size={15} color={d.file ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-gray-4)'} />
<Text fz="xs" fw={700}>{slot.short}</Text>
</Group>
<Text fz="xs" c="dimmed">{d.certNumber || '—'}</Text>
<Text fz="xs" c="dimmed">{d.issuer || '—'}</Text>
<Text fz="xs" c="dimmed">Issued: {d.issueDate || '—'}</Text>
{d.file && <Text fz="xs" c="teal.7" truncate>{d.file.name}</Text>}
</div>
);
})}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Medical Certificate</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Certificate No." value={medCertNumber} />
<ReviewRow label="Issuing Centre" value={medIssuer} />
<ReviewRow label="Issue Date" value={medIssueDate} />
<ReviewRow label="Expiry Date" value={medExpiryDate} />
<ReviewRow label="Document" value={medFile?.name ?? '—'} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Payment</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Payment Method" value={paymentMethod === 'cbe' ? 'CBE Bank Transfer' : 'Telebirr'} />
<ReviewRow label="Transaction Reference" value={paymentRef} />
<ReviewRow label="Payment Date" value={paymentDate} />
<ReviewRow label="Total Paid" value={`ETB ${TOTAL.toFixed(2)}`} />
<ReviewRow label="Receipt" value={paymentFile?.name ?? 'Not uploaded'} />
</SimpleGrid>
</Paper>
</Stack>
)}
{/* Navigation */}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/seaman-book')}>Cancel</Button>
<Group gap="sm">
{active > 0 && (
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={prev}>Previous</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={next} disabled={!canNext()}>
Next Step
</Button>
) : (
<Button color="blue" leftSection={<IconBook2 size={16} />} onClick={handleSubmit} loading={submitting}>
Submit Application
</Button>
)}
</Group>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,310 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Stepper,
Text,
ThemeIcon,
Timeline,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconPrinter,
IconShield,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Mock data — replace with real API
// ---------------------------------------------------------------------------
const ELIGIBILITY = {
hasProfile: true,
hasNationalId: true,
hasMedicalCert: true,
medicalExpiry: '2026-03-14',
bstComplete: true,
bstItems: [
{ label: 'Personal Survival Techniques (PST)', done: true },
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true },
{ label: 'Elementary First Aid (EFA)', done: true },
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: true },
{ label: 'Sexual Harassment Prevention', done: true },
],
};
const MOCK_APPLICATION: SeamanBookApp | null = null;
interface SeamanBookApp {
id: string;
submittedAt: string;
status: string;
remarks: string;
timeline: { date: string | null; event: string; done: boolean }[];
}
const STATUS_COLOR: Record<string, string> = {
'Under Review': 'yellow',
'Approved': 'teal',
'Rejected': 'red',
'Correction Required': 'orange',
'Ready for Collection': 'blue',
};
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
return (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
</ThemeIcon>
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
</Group>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SeamanBookPage() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length;
const isEligible =
ELIGIBILITY.hasProfile &&
ELIGIBILITY.hasNationalId &&
ELIGIBILITY.hasMedicalCert &&
ELIGIBILITY.bstComplete;
const handleApply = async () => {
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1400));
setSubmitting(false);
setSubmitted(true);
notify.success('Seaman Book application submitted successfully! Reference: SB-APP-2024-002');
};
const activeStep = MOCK_APPLICATION
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1
: -1;
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records your sea service and must be
held before joining any vessel.
</Text>
</div>
{/* Active application status */}
{MOCK_APPLICATION && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text>
<Text fz="xs" c="dimmed">Submitted {MOCK_APPLICATION.submittedAt}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[MOCK_APPLICATION.status] ?? 'gray'} variant="light" size="lg">
{MOCK_APPLICATION.status}
</Badge>
</Group>
{MOCK_APPLICATION.remarks && (
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
</Alert>
)}
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{MOCK_APPLICATION.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
{MOCK_APPLICATION.status === 'Ready for Collection' && (
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
Your Seaman Book is ready. Please visit the EMA office to collect it. Bring your National ID.
</Alert>
)}
</Paper>
)}
{/* No active application — eligibility + apply */}
{!submitted && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color={isEligible ? 'teal' : 'orange'} size={36} radius="md">
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
</Group>
<Stack gap="sm">
<EligibilityItem label="Profile completed (name, DOB, nationality)" ok={ELIGIBILITY.hasProfile} />
<EligibilityItem label="National ID / Fayda uploaded" ok={ELIGIBILITY.hasNationalId} />
<EligibilityItem label="Valid medical certificate uploaded" ok={ELIGIBILITY.hasMedicalCert} />
<Divider label="Basic Safety Training (all 5 required)" labelPosition="left" my={4} />
{ELIGIBILITY.bstItems.map((item) => (
<EligibilityItem key={item.label} label={item.label} ok={item.done} />
))}
{!isEligible && (
<Alert variant="light" color="orange" icon={<IconAlertCircle size={15} />} mt="xs" p="sm">
<Text fz="xs">
Complete all requirements above before applying. Missing BST: {5 - bstDone} certificate(s).
</Text>
</Alert>
)}
{isEligible && (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />} mt="xs" p="sm">
<Text fz="xs">You meet all requirements. You may proceed with your application.</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will verify your profile,
documents, medical certificate, and Basic Safety Training certificates. You will be
notified at each stage by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">What will be verified:</Text>
<Stack gap={6}>
{[
'Full seafarer profile',
'National ID / Fayda authenticity',
'Medical certificate validity',
'All 5 Basic Safety Training certificates',
'Passport size photo',
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">Processing time</Text>
<Text fz="sm" fw={600}>57 working days</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">Medical validity</Text>
<Text fz="sm" fw={600}>2 years (STCW)</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
<Text fz="xs">
Application fee will be communicated during the review process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate('/seaman-book/apply')}
loading={submitting}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">About the Seaman Book</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{ icon: IconBook2, title: 'Official Identity', desc: 'Internationally recognized maritime identity document required before joining any vessel.' },
{ icon: IconFileDescription, title: 'Service Record', desc: 'Records all your sea service, vessel assignments, and employment history.' },
{ icon: IconShield, title: 'STCW Compliance', desc: 'Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.' },
].map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>{desc}</Text>
</Box>
))}
</SimpleGrid>
</Paper>
</Stack>
);
}

View File

@@ -16,6 +16,11 @@ export const am: Translations = {
nav: {
dashboard: 'ዳሽቦርድ',
seafarerRegistry: 'የመርከበኞች ምዝገባ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
endorsements: 'ማረጋገጫዎች',
documents: 'ሰነዶቼ',
notifications: 'ማሳወቂያዎች',
profile: 'መገለጫ',
support: 'እገዛና ድጋፍ',
collapseSidebar: 'ሰብስብ',

View File

@@ -14,6 +14,11 @@ export const en = {
nav: {
dashboard: 'Dashboard',
seafarerRegistry: 'Seafarer Registry',
myApplication: 'My Application',
certificates: 'Certificates',
endorsements: 'Endorsements',
documents: 'My Documents',
notifications: 'Notifications',
profile: 'Profile',
support: 'Help & Support',
collapseSidebar: 'Collapse',

View File

@@ -1,10 +1,15 @@
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLayoutDashboard,
IconLifebuoy,
IconBell,
IconFolderOpen,
IconHeadset,
IconHome2,
IconList,
IconUser,
IconRubberStamp,
IconSend,
IconShieldCheck,
IconUserCircle,
} from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -16,16 +21,27 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2 },
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconLifebuoy },
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
{ to: '/notifications',label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset },
];
const PAGE_META: Record<string, { i18nKey: string }> = {
'/dashboard': { i18nKey: 'nav.dashboard' },
'/dashboard': { i18nKey: 'nav.dashboard' },
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
'/profile': { i18nKey: 'nav.profile' },
'/seaman-book': { i18nKey: 'nav.myApplication' },
'/certificates': { i18nKey: 'nav.certificates' },
'/endorsements': { i18nKey: 'nav.endorsements' },
'/documents': { i18nKey: 'nav.documents' },
'/notifications':{ i18nKey: 'nav.notifications' },
'/profile': { i18nKey: 'nav.profile' },
'/support': { i18nKey: 'nav.support' },
};
export function PortalLayout() {

View File

@@ -1,6 +1,5 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
@@ -16,19 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
<ErrorBoundary>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</AuthProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</QueryClientProvider>
</Provider>
</ErrorBoundary>

View File

@@ -1,13 +1,16 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { ProfileGuard } from './components/ProfileGuard';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
// Profile setup
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { ProfilePage } from './features/profile/pages/ProfilePage';
@@ -16,17 +19,18 @@ import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegi
import { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
// IAM (admin user management) — kept reachable but isolated under its own
// provider so it does not depend on the portal's provider tree.
import {
AppProviders as IamProviders,
UserManagementLayout,
UserManagementPage,
} from '@tria-plc/iamui-common';
// Phase 1 pages
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
function IsolatedIam({ children }: { children: ReactNode }) {
return <IamProviders>{children}</IamProviders>;
}
// Phase 2 — CoC / CoP
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
// Phase 3 — Endorsement
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
export const router = createBrowserRouter([
// Public auth pages
@@ -42,38 +46,49 @@ export const router = createBrowserRouter([
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
path: '/forgot-password',
},
{
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
path: '/profile-setup',
},
// Portal — protected
{
element: (
<ProtectedRoute>
<I18nextProvider i18n={i18n}>
<PortalLayout />
</I18nextProvider>
<ProfileGuard>
<I18nextProvider i18n={i18n}>
<PortalLayout />
</I18nextProvider>
</ProfileGuard>
</ProtectedRoute>
),
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
// Seafarer
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
// Phase 1
{ path: '/documents', element: <DocumentVaultPage /> },
{ path: '/seaman-book', element: <SeamanBookPage /> },
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
{ path: '/notifications', element: <NotificationsPage /> },
// Phase 2 — CoC / CoP
{ path: '/certificates', element: <CertificatesPage /> },
{ path: '/certificates/apply', element: <CoCApplicationPage /> },
// Phase 3 — Endorsement
{ path: '/endorsements', element: <EndorsementPage /> },
// General
{ path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },
],
},
// IAM admin user management (isolated providers) — protected
{
element: (
<ProtectedRoute>
<IsolatedIam>
<UserManagementLayout />
</IsolatedIam>
</ProtectedRoute>
),
children: [{ path: '/users', element: <UserManagementPage /> }],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);

View File

@@ -8,15 +8,16 @@ import {
refreshAccessToken,
logout,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
configureAuthStorage('ema-portal');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
if (token && user) {
return { token, user, isAuthenticated: true };
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
}
return undefined;
})();

View File

@@ -2,7 +2,6 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@tria-plc/iamui-common/styles.css';
import './app/theme/portal.css';
import './app/i18n/config';

7255
exam-result.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@ export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types';
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Alert,
Anchor,
Button,
Center,
@@ -35,6 +36,7 @@ export function ForgotPasswordPage() {
const { appName } = useAuthConfig();
const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
@@ -56,8 +58,11 @@ export function ForgotPasswordPage() {
const onSubmit = async (values: FormValues) => {
try {
await sendResetLink(values.email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
@@ -67,8 +72,11 @@ export function ForgotPasswordPage() {
try {
await sendResetLink(sentTo);
notify.success('Reset link sent again');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
@@ -160,6 +168,12 @@ export function ForgotPasswordPage() {
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Alert,
Anchor,
Button,
Checkbox,
@@ -25,9 +26,10 @@ import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice';
import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
import { authStorage } from '../utils/auth-storage';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
@@ -43,8 +45,10 @@ export function LoginPage() {
useAuthConfig();
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [serverError, setServerError] = useState<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const {
register,
@@ -70,15 +74,46 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
if (me.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
let hasProfile = false;
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`,
method: 'GET',
}).unwrap();
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
hasProfile = true;
}
} catch {
// profile not found — redirect to setup
}
} catch {
notify.error('Invalid email or password');
if (!me.isPhoneNumberVerified) {
navigate('/otp-verify', {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
needsProfile: !hasProfile,
},
});
return;
}
if (!hasProfile) {
navigate('/profile-setup');
return;
}
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
} finally {
setIsLoading(false);
}
@@ -96,6 +131,12 @@ export function LoginPage() {
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import {
Alert,
Anchor,
Button,
Center,
@@ -37,14 +38,16 @@ export function OTPVerificationPage() {
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as
| { email?: string; phoneNumber?: string }
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const needsProfile = state?.needsProfile ?? false;
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
const [serverError, setServerError] = useState<string | null>(null);
const {
control,
@@ -70,9 +73,12 @@ export function OTPVerificationPage() {
}).unwrap();
notify.success('Phone number verified successfully');
navigate(loginRedirectPath);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
@@ -81,15 +87,19 @@ export function OTPVerificationPage() {
if (secondsLeft > 0 || resending) return;
try {
await resendTrigger({
url: '/auth/resend-otp',
method: 'POST',
body: { email },
url: '/auth/generate-verification-code',
method: 'PATCH',
body: { email, phoneNumber, type: 'verify-phone-number' },
}).unwrap();
notify.success('Verification code resent to your email');
setSecondsLeft(RESEND_SECONDS);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
setServerError(null);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
@@ -117,6 +127,12 @@ export function OTPVerificationPage() {
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Controller
@@ -126,8 +142,8 @@ export function OTPVerificationPage() {
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="number"
inputMode="numeric"
type="text"
inputMode="text"
oneTimeCode
size="md"
gap="sm"

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import {
Alert,
Anchor,
Button,
Checkbox,
@@ -27,7 +28,8 @@ import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess } from '../store/auth.slice';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
const schema = z
@@ -66,11 +68,13 @@ export function SignupPage() {
const dispatch = useDispatch();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
@@ -107,15 +111,25 @@ export function SignupPage() {
}),
);
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
navigate(loginRedirectPath);
navigate('/profile-setup');
} else {
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
state: {
email: values.email,
phoneNumber: values.phoneNumber,
needsProfile: true,
},
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
}
};
@@ -135,6 +149,12 @@ export function SignupPage() {
</Text>
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
{serverError}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -1,11 +1,12 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
currentProfile: null,
};
const authSlice = createSlice({
@@ -22,23 +23,36 @@ const authSlice = createSlice({
state.user = action.payload;
authStorage.setUser(action.payload);
},
setCurrentProfile(state, action: PayloadAction<CurrentProfile>) {
state.currentProfile = action.payload;
authStorage.setProfile(action.payload);
},
clearCurrentProfile(state) {
state.currentProfile = null;
authStorage.removeProfile();
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
state.currentProfile = null;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
const profile = authStorage.getProfile<CurrentProfile>();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
if (profile) {
state.currentProfile = profile;
}
}
},
},
});
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
export const { loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -19,6 +19,7 @@ export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
currentProfile: CurrentProfile | null;
}
export interface LoginPayload {
@@ -26,3 +27,57 @@ export interface LoginPayload {
refreshToken: string;
isPhoneNumberVerified: boolean;
}
export interface CurrentProfileAddress {
id: string;
idType: string;
idNumber: string;
nationality: string;
regionId: string | null;
cityId: string | null;
subCityId: string | null;
woredaId: string | null;
kebeleId: string | null;
streetAddress: string | null;
houseNumber: string | null;
primaryPhoneNumber: string;
secondaryPhoneNumber: string | null;
email: string | null;
website: string | null;
postalAddress: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
emergencycontactRelation: string | null;
isActive: boolean;
}
export interface CurrentProfileProfession {
id: string;
departmentId: string;
name: { en: string };
description: { en: string };
isActive: boolean;
}
export interface CurrentProfile {
id: string;
userId: string;
professionId: string;
addressId: string;
type: string;
firstName: string;
middleName: string;
lastName: string;
gender: string;
dob: string;
pob: string;
maritalStatus: string;
isComplete: boolean;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;
}
export interface CurrentProfileState {
profile: CurrentProfile | null;
}

View File

@@ -21,8 +21,19 @@ export const authStorage = {
}
},
setUser: <T>(u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)),
getProfileId: () => localStorage.getItem(key('profile-id')) ?? undefined,
setProfileId: (id: string) => localStorage.setItem(key('profile-id'), id),
getProfile: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null;
} catch {
return null;
}
},
setProfile: <T>(p: T) => localStorage.setItem(key('current-profile'), JSON.stringify(p)),
removeProfile: () => localStorage.removeItem(key('current-profile')),
clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) =>
[key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) =>
localStorage.removeItem(k),
);
document.cookie =

View File

@@ -1,3 +1,4 @@
export * from './lib/input/BilingualInput';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';

View File

@@ -12,9 +12,20 @@ export interface BilingualValue {
}
interface BilingualInputProps
extends Omit<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
extends Omit<TextInputProps, 'value' | 'onChange' | 'placeholder' | 'rightSection' | 'rightSectionWidth'> {
value: BilingualValue;
onChange: (value: BilingualValue) => void;
placeholder?: string | BilingualValue;
}
function resolvePlaceholder(placeholder: string | BilingualValue | undefined, lang: 'en' | 'am'): string {
if (!placeholder) {
return lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ';
}
if (typeof placeholder === 'string') {
return placeholder;
}
return placeholder[lang];
}
export function BilingualInput({
@@ -33,7 +44,7 @@ export function BilingualInput({
<TextInput
label={label}
required={required}
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
placeholder={resolvePlaceholder(placeholder, lang)}
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={

View File

@@ -1,5 +1,6 @@
import {
Anchor,
Box,
Burger,
Group,
Indicator,
@@ -52,7 +53,7 @@ export function AppHeader({
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
<UnstyledButton
<Box
onClick={isMobile ? onToggleNav : onToggleSidebar}
style={{
display: 'flex',
@@ -91,7 +92,7 @@ export function AppHeader({
burger: { '--burger-color': 'currentColor' },
}}
/>
</UnstyledButton>
</Box>
{/* Breadcrumbs — card container with pill-style crumbs */}
<Group

View File

@@ -103,7 +103,7 @@ export function AppSidebar({
if (collapsed) {
return (
<Tooltip key={item.label} label={item.label} position="right" withArrow>
<Tooltip key={item.label} label={t(item.label)} position="right" withArrow>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
@@ -127,7 +127,7 @@ export function AppSidebar({
<NavLink
key={item.label}
active={active}
label={item.label}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => onNavigate(item)}
variant="light"

Binary file not shown.

4025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +3,14 @@
"version": "0.0.1",
"private": true,
"scripts": {
"backoffice": "npm run build:user-management && nx serve @ema-platform/backoffice",
"backoffice": "nx serve @ema-platform/backoffice",
"portal": "nx serve @ema-platform/portal",
"dev:all": "npm run build:user-management && nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
"build:backoffice": "nx build @ema-platform/backoffice",
"build:portal": "nx build @ema-platform/portal",
"lint": "nx run-many -t lint",
"test": "nx run-many -t test",
"format": "prettier --write .",
"build:user-management": "cd user-management-config && npm run build",
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
"format": "prettier --write ."
},
"dependencies": {
"@daypicker/ethiopic": "^10.0.1",
@@ -27,7 +25,7 @@
"@reduxjs/toolkit": "^2.11.2",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.99.0",
"@tria-plc/iamui-common": "1.1.1",
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",

File diff suppressed because one or more lines are too long

Submodule user-management deleted from e025182e92

View File

@@ -1,223 +0,0 @@
/**
* fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset.
*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │
* │ module. At submodule-split time this whole folder moves to the host repo. │
* │ It is fully self-contained — no imports from the module. │
* └─────────────────────────────────────────────────────────────────────────┘
*
* WHAT IT GIVES YOU
* - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray
* - The FHC layout design tokens (brick-gradient sidebar, glassy header,
* page background, brand colors, sizes) under `theme.other.fhcLayout`
* (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads
* these via useFhcLayout()
* - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults
*
* HOW TO USE — in app-config/project.theme.ts:
*
* import { fhcMantineTheme } from "./fhc.theme";
*
* export const projectTheme: DesignConfig = {
* typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
* mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in
* };
*
* Load the font once in index.html:
* <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
*/
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
/** Mantine 10-shade color scales used across the FHC UI. */
export const FHC_COLORS = {
fhcBlue: [
"#EEF4FC",
"#D9E8FA",
"#BCD5F5",
"#96BDEB",
"#6FA4E0",
"#4A90E2",
"#4b7fe5",
"#2C669D",
"#224F7A",
"#173654",
],
fhcBrick: [
"#F6ECE8",
"#EACFC4",
"#DBAD99",
"#C9876B",
"#B86B49",
"#A85735",
"#8C462B",
"#703622",
"#55281A",
"#3D1E14",
],
fhcGold: [
"#FFFBE6",
"#FFF3BF",
"#FEE98A",
"#FCDD57",
"#F9CF2F",
"#FFD700",
"#D9B700",
"#B39400",
"#8C7300",
"#665300",
],
fhcGray: [
"#F8FAFC",
"#F1F5F9",
"#E2E8F0",
"#CBD5E1",
"#94A3B8",
"#64748B",
"#475569",
"#334155",
"#1E293B",
"#0F172A",
],
} as const;
/**
* Layout design tokens — the brick-gradient sidebar, glassy header, page
* surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`.
*/
export const FHC_LAYOUT = {
sidebar: {
bg: "linear-gradient(180deg, #1A3A5C 0%, #0F2440 100%)",
headerBg: "rgba(26, 58, 92, 0.82)",
footerBg: "rgba(15, 36, 64, 0.62)",
border: "rgba(255,255,255,0.10)",
text: "rgba(255,255,255,0.76)",
mutedText: "rgba(255,255,255,0.42)",
childText: "rgba(255,255,255,0.68)",
activeText: "#FFFFFF",
iconBg: "rgba(255,255,255,0.06)",
iconActiveBg: "rgba(255,255,255,0.12)",
hoverBg: "rgba(255,255,255,0.08)",
activeBg: "rgba(255,255,255,0.15)",
activeBorder: "rgba(255,255,255,0.14)",
sectionLine: "rgba(255,255,255,0.10)",
rail: "linear-gradient(180deg, #4A90E2 0%, #1A3A5C 100%)",
},
header: {
bg: "rgba(255,255,255,0.92)",
border: "rgba(15, 23, 42, 0.08)",
searchBg: "#F9FAFB",
searchBorder: "#E5E7EB",
title: "#1F2937",
subtitle: "#6B7280",
},
page: {
bg: "#F8FAFC",
cardBg: "rgba(255,255,255,0.92)",
},
brand: {
brick: "#1A3A5C",
brickDark: "#0F2440",
blue: "#4A90E2",
blueDark: "#4b7fe5",
gold: "#4A90E2",
text: "#1F2937",
},
sizes: {
sidebarExpanded: 288,
sidebarCollapsed: 80,
headerHeight: 64,
},
} as const;
/**
* Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail
* and sizes are intentionally kept (they already read well on dark), while the
* glassy white header, page background, card surfaces and dark text are flipped
* to dark equivalents.
*/
export const FHC_LAYOUT_DARK = {
...FHC_LAYOUT,
header: {
bg: "rgba(26, 27, 30, 0.92)",
border: "rgba(255,255,255,0.08)",
searchBg: "#25262B",
searchBorder: "#2C2E33",
title: "#F1F5F9",
subtitle: "#9CA3AF",
},
page: {
bg: "#141517",
cardBg: "rgba(26, 27, 30, 0.92)",
},
brand: {
...FHC_LAYOUT.brand,
text: "#F1F5F9",
},
} as const;
/**
* Full Mantine theme override carrying the FHC palettes, layout tokens,
* typography, radii, shadows and component defaults. Pass this as the
* `mantineTheme` escape hatch in project.theme.ts.
*
* Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under
* `other` — the module's useFhcLayout() reads the matching one per color scheme.
*/
export const fhcMantineTheme: MantineThemeOverride = {
fontFamily: "Plus Jakarta Sans, sans-serif",
headings: {
fontFamily: "Plus Jakarta Sans, sans-serif",
},
defaultRadius: "md",
radius: {
xs: "6px",
sm: "8px",
md: "10px",
lg: "14px",
xl: "18px",
},
shadows: {
xs: "0 1px 2px rgba(15, 23, 42, 0.04)",
sm: "0 2px 8px rgba(15, 23, 42, 0.06)",
md: "0 4px 20px rgba(15, 23, 42, 0.08)",
lg: "0 8px 30px rgba(15, 23, 42, 0.12)",
},
colors: {
fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple,
fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple,
fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple,
fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple,
},
other: {
fhcLayout: FHC_LAYOUT,
fhcLayoutDark: FHC_LAYOUT_DARK,
},
components: {
Paper: {
defaultProps: {
radius: "lg",
shadow: "sm",
},
},
NavLink: {
defaultProps: {
radius: "md",
},
},
},
};
/**
* Optional convenience: the bits of a DesignConfig that carry the FHC look.
* Spread this into your projectTheme if you also want FHC as the primary brand
* (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
*/
export const fhcDesignPreset = {
colors: { primary: "#4b7fe5" },
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
shape: { radius: "10px" },
mantineTheme: fhcMantineTheme,
};

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"
/>
<title>EMA Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -1,27 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
// Consume the reusable module via its public barrel (@/ → ../user-management/src).
import i18n from "@/i18n";
import { UserManagementApp } from "@/index";
// Your project's config lives HERE in the host folder (resolved via @app-config).
// The module never imports it; the host passes it in.
import { projectTheme } from "@app-config/project.theme";
// Override submodule translation texts for EMA branding
// (the submodule is a readonly git submodule, so we patch i18n at runtime here)
i18n.addResourceBundle("en", "translation", {
auth: {
welcomeHeadline: "Welcome to EMA Portal",
paperlessOffice: "Ethiopian Maritime Authority",
welcomeSubtext: "Sign in to access your maritime services efficiently.",
},
organization: {
"Back to EMA": "Back to EMA",
},
}, true, true);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<UserManagementApp config={projectTheme} />
</StrictMode>
);

View File

@@ -1 +0,0 @@
../user-management/node_modules

View File

@@ -1,12 +0,0 @@
{
"name": "user-management-host",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.",
"scripts": {
"dev": "vite --port 4202 --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --port 4202 --host"
}
}

View File

@@ -1,7 +0,0 @@
// Tailwind is handled by the @tailwindcss/vite plugin (see vite.config.ts), so
// PostCSS needs no plugins here. This local config exists to stop Vite from
// walking up to the monorepo root postcss.config.js (Tailwind v3), which would
// conflict with this package's Tailwind v4 setup.
export default {
plugins: {},
};

View File

@@ -1,220 +0,0 @@
/**
* project.theme.ts — HOST-OWNED config for THIS project / organisation (FHC).
*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ Lives in app-config/, OUTSIDE the user-management module. The module │
* │ never imports this file — the host passes it in via │
* │ <UserManagementApp config={projectTheme} /> (see ../src/main.tsx). │
* │ At submodule-split time, this whole folder moves to the host repo. │
* └─────────────────────────────────────────────────────────────────────────┘
*
* Every field is optional — remove lines you don't need to override.
*
* Flow:
* project.theme.ts → design.config.ts (module engine) → CSS vars + Mantine theme
* TenantConfig.ts → overrides --primary at runtime per hostname
*
* The TenantConfig layer runs AFTER this, so per-hostname primary-color overrides
* still work on top of whatever you set here.
*/
import type { DesignConfig } from "@/config/design.config";
import { fhcMantineTheme } from "./fhc.theme";
export const projectTheme: DesignConfig = {
// ─────────────────────────────────────────────────────────────────────────
// BRANDING
// Replace with your organisation's assets.
// ─────────────────────────────────────────────────────────────────────────
brand: {
appName: "EMA Portal", // EMA — shown in the browser tab
logoUrl: "/assets/ema-logo.png",
faviconUrl: "/assets/ema-logo.png",
},
// ─────────────────────────────────────────────────────────────────────────
// COLORS
// Change `primary` to your brand hex and everything cascades automatically.
// Shades primary-50 → primary-950 are computed via CSS color-mix in index.css.
// TenantConfig overrides this per-hostname, so localhost vs edrsc.com can
// still have different colors.
// ─────────────────────────────────────────────────────────────────────────
colors: {
primary: "#4b7fe5", // FHC blue (fhcBlue-6) — buttons, links, active states
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
// // "#2563eb" blue | "#7c3aed" purple
// // "#16a34a" green | "#dc2626" red
// // "#f59e0b" amber | "#0284c7" sky
// primaryForeground: "#ffffff", // text on primary-colored bg — rarely needs changing
// secondary: "#f1f5f9", // TODO: subtle secondary UI color
// background: "#ffffff", // TODO: page background
// foreground: "#0f172a", // TODO: main text color
// border: "#e2e8f0", // TODO: input / card borders
// muted: "#f8fafc", // TODO: disabled input / tag backgrounds
// mutedForeground: "#94a3b8", // TODO: placeholder / helper text
// card: "#ffffff", // TODO: card background (if different from page)
// sidebar: "#f8fafc", // TODO: sidebar background
// danger: "#dc2626", // TODO: error / destructive color
},
// ─────────────────────────────────────────────────────────────────────────
// TYPOGRAPHY
// Load the font FIRST in index.html (Google Fonts link or @font-face) then
// set fontFamily here. The fallback chain is used if the custom font fails.
// ─────────────────────────────────────────────────────────────────────────
typography: {
fontFamily: "Plus Jakarta Sans, Inter, ui-sans-serif, system-ui, sans-serif",
// // TODO: "Poppins, Inter, sans-serif"
// // TODO: "Cairo, Inter, sans-serif" (Arabic)
// // TODO: "Noto Serif Ethiopic, serif" (Amharic)
// headingFontFamily: undefined, // TODO: separate heading font if desired
// baseFontSize: "16px", // TODO: "14px" for compact dashboards
},
// ─────────────────────────────────────────────────────────────────────────
// SHAPE
// ─────────────────────────────────────────────────────────────────────────
shape: {
radius: "0.625rem", // TODO: "0" sharp | "0.5rem" subtle | "1rem" very rounded
},
// ─────────────────────────────────────────────────────────────────────────
// SHADOWS
// Leave commented to use Mantine/Tailwind defaults.
// ─────────────────────────────────────────────────────────────────────────
// shadows: {
// card: "0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)",
// dropdown: "0 8px 30px rgba(0,0,0,0.12)",
// modal: "0 20px 60px rgba(0,0,0,0.16)",
// },
// ─────────────────────────────────────────────────────────────────────────
// MANTINE COMPONENT DEFAULTS
// These become the <MantineProvider theme> defaults for every component.
// ─────────────────────────────────────────────────────────────────────────
components: {
buttonDefaultVariant: "filled", // TODO: "light" | "outline" | "subtle"
inputDefaultSize: "sm", // TODO: "xs" | "md" | "lg"
inputRadius: "md", // TODO: "xs" | "lg" | "xl"
modalRadius: "lg", // TODO: "md" | "xl"
tableHighlightOnHover: true,
tableStriped: false, // TODO: "odd" | "even" | true
},
// ─────────────────────────────────────────────────────────────────────────
// USER-MANAGEMENT LAYOUT / NAVIGATION
// Pick the navigation chrome and style the side menu — all from here.
// "classic" → app-wide SIDE MENU, no top tabs
// "legacy" → top TAB bar, no side menu
// Each value is also exposed as a --um-* CSS var, so tweaks apply instantly.
// ─────────────────────────────────────────────────────────────────────────
layout: {
userManagementView: "legacy", // "legacy" → top-tab UI, "classic" → side menu
// showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR
// ── Dimensions ──────────────────────────────────────────────────────────
sidebarWidth: "288px", // TODO: expanded side-menu width
sidebarCollapsedWidth: "80px", // TODO: icon-only width
headerHeight: "64px", // TODO: top bar height
// contentMaxWidth: "1440px", // TODO: cap the content column
// ── Side-menu skin (defaults follow the FHC brick theme) ───────────────
sidebarBackground: "linear-gradient(180deg, #1A3A5C 0%, #0F2440 100%)",
sidebarColor: "rgba(255,255,255,0.76)",
sidebarMutedColor: "rgba(255,255,255,0.42)",
sidebarActiveBackground: "rgba(255,255,255,0.15)",
sidebarActiveColor: "#FFFFFF",
sidebarHoverBackground: "rgba(255,255,255,0.08)",
sidebarBorder: "rgba(255,255,255,0.10)",
sidebarRail: "linear-gradient(180deg, #4A90E2 0%, #1A3A5C 100%)",
sidebarBrandLabel: "EMA Portal",
sidebarBrandSublabel: "User Management",
// ── THE MENU (data, shared by the side menu AND the top tabs) ──────────
// Edit/add/remove freely. `icon` is a name from the registry in
// navConfig.tsx (users, dashboard, content, position, settings, excel,
// archive, units, activity, organizations, …). `label` is an i18n key
// under "organization.<label>" (raw string shown if no translation).
// Remove this array entirely to fall back to the built-in defaults.
//
// SHOW / HIDE A MENU: set `enabled: false` on any item to stop it
// rendering in BOTH the side menu and the top tabs — without deleting it.
// Omitting `enabled` (or `true`) keeps it visible. Toggle these per project.
navItems: [
// The menu is ROLE-FILTERED (see navConfig.tsx): each role sees only its own
// block. Every href below has a matching route in the embedded router
// (src/App.tsx), which is now a superset of org-admin + super-admin routes.
// ── Org-admin / unit-admin surface ──
{ label: "dashboard", href: "/user-management/user_management-dashboard", icon: "dashboard", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "userManagement", href: "/user-management/user_management", icon: "users", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "contentManagement", displayLabel: "contentManagement", href: "/user-management/content-management", icon: "content", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Position", displayLabel: "positionTypes", href: "/user-management/position-management", icon: "position", roles: ["admin", "unit_admin", "super_admin"], isPrimary: true, enabled: true },
{ label: "settings", displayLabel: "settings", href: "/user-management/organization-settings", icon: "settings", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Bulk", displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: "excel", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archives", icon: "archive", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
{ label: "Archived Units & Positions", displayLabel: "Archived Units & Positions", href: "/user-management/archived", icon: "units", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
// ── Super-admin surface (routes now wired in App.tsx) ──
{ label: "dashboard", href: "/user-management/dashboard", icon: "dashboard", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "organizations", href: "/user-management/organizations", icon: "organizations", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "organizationAdmins", href: "/user-management/organization_admins", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "externalUsers", href: "/user-management/external_users", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Migrated Records", displayLabel: "migratedRecords", href: "/user-management/migrated-records-management", icon: "file", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archive-users", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "Archived Organizations", displayLabel: "Archived Organizations", href: "/user-management/archived-organizations", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
{ label: "activityLog", href: "/user-management/activity_log", icon: "activity", roles: ["super_admin"], isPrimary: false, enabled: true },
{ label: "setting", href: "/user-management/settings", icon: "settings", roles: ["super_admin"], isPrimary: false, enabled: true },
{ label: "Letter Template", href: "/user-management/templates", icon: "file", roles: ["super_admin"], isPrimary: false, enabled: true },
// ── Return to host app ─────────────────────────────────────────────────
// Clicking navigates the HOST (parent window) to the destination. The
// /return/ prefix is intercepted by UserManagementHostPage.tsx to tell the
// host to navigate itself, not the iframe.
{ label: "Back to EMA", href: "/return/dashboard", icon: "dashboard", roles: ["admin", "unit_admin", "super_admin"], isPrimary: false, enabled: true },
],
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
menuBackground: "#ffffff", // TODO: tab bar background
menuColor: "#334155", // inactive tab text
menuActiveColor: "#4b7fe5", // active tab text (FHC blue)
menuActiveBorderColor: "#4b7fe5", // active tab underline
menuHoverColor: "#4b7fe5", // tab hover text
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
modalAccentColor: "#4b7fe5", // blue top strip
modalHeaderBackground: "#EEF4FC", // header bg (view)
modalHeaderEditBackground: "#D9E8FA", // header bg (edit)
modalIconBackground: "#D9E8FA", // header icon chip bg
modalIconColor: "#4b7fe5", // header icon chip color
modalTitleColor: "#1F2937", // modal title text
modalFocusColor: "#4b7fe5", // input focus ring inside modals
modalSurface: "#ffffff", // modal body surface
},
// ─────────────────────────────────────────────────────────────────────────
// EXTRA CSS VARS
// Inject any CSS custom property that isn't covered above.
// Keys are variable names WITHOUT the leading "--".
// ─────────────────────────────────────────────────────────────────────────
// cssVars: {
// "sidebar-width": "260px",
// "header-height": "64px",
// "content-max-width": "1440px",
// "custom-gradient": "linear-gradient(135deg, #18aa9d 0%, #0f7a70 100%)",
// },
// ─────────────────────────────────────────────────────────────────────────
// MANTINE THEME ESCAPE HATCH
// Any Mantine theme key — merged on top of everything above.
// Full list: https://mantine.dev/theming/theme-object/
// ─────────────────────────────────────────────────────────────────────────
// The FHC look & feel preset — provides the fhcBlue/fhcBrick/fhcGold/fhcGray
// palettes and the `other.fhcLayout` / `other.fhcLayoutDark` tokens that the
// "classic" user-management view renders with. Lives alongside this file in
// app-config/ so the whole host config moves together at submodule-split time.
mantineTheme: fhcMantineTheme,
};

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"skipLibCheck": true,
"strict": false,
"baseUrl": ".",
"paths": {
"@/*": ["../user-management/src/*"],
"@app-config/*": ["./*"]
}
},
"include": ["main.tsx", "project.theme.ts", "fhc.theme.ts", "../user-management/src"]
}

View File

@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["vite.config.ts"]
}

View File

@@ -1,107 +0,0 @@
import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
// Same-origin sub-path the host server serves the module from. `base` makes
// built asset URLs resolve under it AND is read back inside the module
// (import.meta.env.BASE_URL) to set the router basename. Override with UM_BASE.
const base = env.UM_BASE || "/_um/";
// Build straight into the host app's public dir so its ONE server serves the
// module at <origin>/_um/ — no second server, same origin as the host.
const outDir = path.resolve(__dirname, "../apps/backoffice/public/_um");
return {
base,
plugins: [
tailwindcss(),
react(),
{
// TinyMCE is self-hosted; the module references it at the ABSOLUTE path
// /tinymce/..., which resolves at the host origin. Mirror the assets into
// BOTH the module public dir AND the host public root. Regenerated on
// build, so neither copy is a hand-managed artifact.
name: "copy-tinymce-assets",
buildStart() {
const src = path.resolve(__dirname, "node_modules/tinymce");
const dests = [
path.resolve(__dirname, "../user-management/public/tinymce"),
path.resolve(__dirname, "../apps/backoffice/public/tinymce"),
];
const runtimeEntries = [
"tinymce.min.js",
"icons",
"models",
"plugins",
"skins",
"themes",
];
if (!fs.existsSync(src)) {
throw new Error(
"[copy-tinymce-assets] node_modules/tinymce not found in this app. Run `npm install` here first."
);
}
for (const dest of dests) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of runtimeEntries) {
const entrySrc = path.resolve(src, entry);
const entryDest = path.resolve(dest, entry);
if (!fs.existsSync(entrySrc)) continue;
fs.cpSync(entrySrc, entryDest, { recursive: true, force: true });
}
}
},
},
],
assetsInclude: ["**/*.TTF"],
// Static assets (incl. tinymce/) live in the module's public dir.
publicDir: path.resolve(__dirname, "../user-management/public"),
build: {
rollupOptions: {
external: [
"file-type",
"readable-web-to-node-stream",
"strtok3",
"token-types",
],
},
outDir,
assetsDir: "assets",
sourcemap: false,
emptyOutDir: true,
minify: "esbuild",
},
resolve: {
alias: [
// @app-config = this host folder itself (project.theme.ts / fhc.theme.ts).
{ find: /^@app-config\//, replacement: path.resolve(__dirname) + "/" },
// @/ = the reusable module's source in the SIBLING module folder.
{ find: /^@\//, replacement: path.resolve(__dirname, "../user-management/src") + "/" },
],
// node_modules is linked to the module's, but pin the singletons so the host
// entry and the module code share ONE React 18 (no "invalid hook call").
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query", "@mantine/core", "@mantine/hooks"],
},
server: {
port: env.DEV_PORT ? Number(env.DEV_PORT) : 5173,
strictPort: true,
fs: { allow: [path.resolve(__dirname, "..")] },
},
optimizeDeps: {
exclude: ["file-type", "readable-web-to-node-stream", "strtok3", "token-types"],
},
esbuild: {
drop: mode === "production" ? ["console", "debugger"] : [],
},
define: {
global: {},
},
};
});