mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge pull request #3 from Tria-plc/feature/examination
This commit is contained in:
4
.github/scripts/scan-malware.js
vendored
4
.github/scripts/scan-malware.js
vendored
@@ -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
1
.gitignore
vendored
@@ -30,4 +30,5 @@ temp_interactive_push.bat
|
||||
|
||||
apps/backoffice/public/_um/
|
||||
apps/backoffice/public/tinymce/
|
||||
local-packages/iamui-extracted/
|
||||
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "user-management"]
|
||||
path = user-management
|
||||
url = git@github.com:Tria-plc/iamui.git
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 500–3,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
apps/backoffice/src/app/features/exam/api/exam-api.ts
Normal file
55
apps/backoffice/src/app/features/exam/api/exam-api.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
367
apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
Normal file
367
apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
Normal file
309
apps/backoffice/src/app/features/exam/pages/ExamPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
apps/backoffice/src/app/features/exam/types/exam.ts
Normal file
81
apps/backoffice/src/app/features/exam/types/exam.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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;
|
||||
242
apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
Normal file
242
apps/backoffice/src/app/features/question/pages/QuestionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/backoffice/src/app/features/question/types/question.ts
Normal file
48
apps/backoffice/src/app/features/question/types/question.ts
Normal 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;
|
||||
}
|
||||
46
apps/backoffice/src/app/features/result/api/result-api.ts
Normal file
46
apps/backoffice/src/app/features/result/api/result-api.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
504
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal file
504
apps/backoffice/src/app/features/result/pages/ResultPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/backoffice/src/app/features/result/types/result.ts
Normal file
78
apps/backoffice/src/app/features/result/types/result.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: 'ክፍል ያስፈልጋል',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
apps/backoffice/src/app/store/preferences.slice.ts
Normal file
39
apps/backoffice/src/app/store/preferences.slice.ts
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
17
apps/portal/src/app/components/ProfileGuard.tsx
Normal file
17
apps/portal/src/app/components/ProfileGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 10–15 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/** 0–4 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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>5–7 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export const am: Translations = {
|
||||
nav: {
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
documents: 'ሰነዶቼ',
|
||||
notifications: 'ማሳወቂያዎች',
|
||||
profile: 'መገለጫ',
|
||||
support: 'እገዛና ድጋፍ',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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
7255
exam-result.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/input/BilingualInput';
|
||||
export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
|
||||
@@ -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={
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
Binary file not shown.
4025
package-lock.json
generated
4025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
../user-management/node_modules
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user