mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 19:28:18 +00:00
Merge pull request #3 from Tria-plc/feature/examination
Feature/examination
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,4 +30,5 @@ temp_interactive_push.bat
|
|||||||
|
|
||||||
apps/backoffice/public/_um/
|
apps/backoffice/public/_um/
|
||||||
apps/backoffice/public/tinymce/
|
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,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: LocationDetailProps) {
|
}: LocationDetailProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const typeInfo = locationTypes.find(
|
const typeInfo = locationTypes.find(
|
||||||
(lt) => lt.id === location.locationTypeId,
|
(lt) => lt.id === location.locationTypeId,
|
||||||
);
|
);
|
||||||
@@ -47,7 +48,7 @@ export function LocationDetail({
|
|||||||
return (
|
return (
|
||||||
<Paper p="lg" radius="md" withBorder>
|
<Paper p="lg" radius="md" withBorder>
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Title order={4}>{location.names.en}</Title>
|
<Title order={4}>{location.names[locale]}</Title>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -89,7 +90,7 @@ export function LocationDetail({
|
|||||||
</Badge>
|
</Badge>
|
||||||
{typeInfo && (
|
{typeInfo && (
|
||||||
<Badge size="lg" variant="light" color="teal">
|
<Badge size="lg" variant="light" color="teal">
|
||||||
{typeInfo.names.en}
|
{typeInfo.names[locale]}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -99,15 +100,9 @@ export function LocationDetail({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
{t('location.nameEn')}
|
{t('location.name')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{location.names.en}</Text>
|
<Text size="sm">{location.names[locale]}</Text>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
||||||
{t('location.nameAm')}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">{location.names.am}</Text>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
<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}>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
{t('location.type')}
|
{t('location.type')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{typeInfo.names.en} (Level {typeInfo.level})</Text>
|
<Text size="sm">{typeInfo.names[locale]} ({t('location.level')} {typeInfo.level})</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export function LocationForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
}: LocationFormProps) {
|
}: LocationFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
|
||||||
const isEditing = !!editingLocation;
|
const isEditing = !!editingLocation;
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ export function LocationForm({
|
|||||||
{parentLocation && !isEditing && (
|
{parentLocation && !isEditing && (
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('location.parent')}
|
label={t('location.parent')}
|
||||||
value={parentLocation.names.en}
|
value={parentLocation.names[locale]}
|
||||||
disabled
|
disabled
|
||||||
mb="sm"
|
mb="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -139,7 +140,7 @@ export function LocationForm({
|
|||||||
placeholder={t('location.selectType')}
|
placeholder={t('location.selectType')}
|
||||||
data={allAtLevel.map((lt) => ({
|
data={allAtLevel.map((lt) => ({
|
||||||
value: lt.id,
|
value: lt.id,
|
||||||
label: lt.names.en,
|
label: lt.names[locale],
|
||||||
}))}
|
}))}
|
||||||
{...form.getInputProps('locationTypeId')}
|
{...form.getInputProps('locationTypeId')}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -153,7 +154,7 @@ export function LocationForm({
|
|||||||
</Text>
|
</Text>
|
||||||
{type ? (
|
{type ? (
|
||||||
<Badge size="lg" variant="light" color="blue">
|
<Badge size="lg" variant="light" color="blue">
|
||||||
{type.names.en}
|
{type.names[locale]}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Text size="sm" c="red">
|
<Text size="sm" c="red">
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ interface LocationTypeFormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
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 { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
||||||
const [createType] = useCreateLocationTypeMutation();
|
const [createType] = useCreateLocationTypeMutation();
|
||||||
const [updateType] = useUpdateLocationTypeMutation();
|
const [updateType] = useUpdateLocationTypeMutation();
|
||||||
@@ -49,9 +50,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
level: 1,
|
level: 1,
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
code: (v) => (!v ? 'Code is required' : null),
|
code: (v) => (!v ? t('location.validation.codeRequired') : null),
|
||||||
namesEn: (v) => (!v ? 'English name is required' : null),
|
namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null),
|
||||||
namesAm: (v) => (!v ? 'Amharic name is required' : null),
|
namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null),
|
||||||
level: (v) => (v < 1 ? 'Level must be at least 1' : 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"
|
variant="light"
|
||||||
leftSection={<IconPlus size={16} />}
|
leftSection={<IconPlus size={16} />}
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
|
mt="md"
|
||||||
mb="md"
|
mb="md"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -130,25 +132,25 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="sm" mb="md">
|
<Stack gap="sm" mb="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Code"
|
label={t('location.code')}
|
||||||
placeholder="e.g., COUNTRY, REGION, CITY"
|
placeholder="e.g., COUNTRY, REGION, CITY"
|
||||||
{...form.getInputProps('code')}
|
{...form.getInputProps('code')}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name (English)"
|
label={t('location.nameEn')}
|
||||||
placeholder="English name"
|
placeholder="English name"
|
||||||
{...form.getInputProps('namesEn')}
|
{...form.getInputProps('namesEn')}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name (Amharic)"
|
label={t('location.nameAm')}
|
||||||
placeholder="የአማርኛ ስም"
|
placeholder="የአማርኛ ስም"
|
||||||
{...form.getInputProps('namesAm')}
|
{...form.getInputProps('namesAm')}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Level"
|
label={t('location.level')}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
@@ -179,10 +181,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
<Table striped highlightOnHover>
|
<Table striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Level</Table.Th>
|
<Table.Th>{t('location.level')}</Table.Th>
|
||||||
<Table.Th>Code</Table.Th>
|
<Table.Th>{t('location.code')}</Table.Th>
|
||||||
<Table.Th>Name (EN)</Table.Th>
|
<Table.Th>{t('location.name')}</Table.Th>
|
||||||
<Table.Th>Name (AM)</Table.Th>
|
|
||||||
<Table.Th />
|
<Table.Th />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -199,8 +200,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
{type.code}
|
{type.code}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{type.names.en}</Table.Td>
|
<Table.Td>{type.names[locale]}</Table.Td>
|
||||||
<Table.Td>{type.names.am}</Table.Td>
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import {
|
|||||||
import type { Location } from '../types/location';
|
import type { Location } from '../types/location';
|
||||||
|
|
||||||
export function LocationPage() {
|
export function LocationPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.language as 'en' | 'am';
|
||||||
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||||
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
||||||
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
||||||
@@ -221,7 +222,7 @@ export function LocationPage() {
|
|||||||
>
|
>
|
||||||
<Text mb="md">
|
<Text mb="md">
|
||||||
{t('location.deleteConfirmText', {
|
{t('location.deleteConfirmText', {
|
||||||
name: selectedLocation?.names.en ?? '',
|
name: selectedLocation?.names[locale] ?? '',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end">
|
<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,
|
IconCircleCheckFilled,
|
||||||
IconDeviceDesktop,
|
IconDeviceDesktop,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
|
IconLayoutNavbar,
|
||||||
|
IconLayoutSidebar,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
@@ -46,6 +48,8 @@ import { setUser } from '@ema-platform/auth';
|
|||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
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';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string, fallback: string) {
|
function getInitials(name: string, fallback: string) {
|
||||||
@@ -72,6 +76,7 @@ export function ProfilePage() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||||
|
|
||||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
@@ -572,6 +577,48 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
<Divider />
|
<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 align="flex-start" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconBell size={20} color="var(--mantine-color-gray-6)" />
|
<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: 'ምናሌ',
|
menu: 'ምናሌ',
|
||||||
dashboard: 'ዳሽቦርድ',
|
dashboard: 'ዳሽቦርድ',
|
||||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||||
|
seamanBookQueue: 'የመርከበኞች መጽሐፍ ወረፋ',
|
||||||
|
cocQueue: 'የCoC/CoP ወረፋ',
|
||||||
|
endorsementQueue: 'የማረጋገጫ ወረፋ',
|
||||||
|
seafarerRegistry: 'የመርከበኞች መዝገብ',
|
||||||
|
applications: 'ማመልከቻዎች',
|
||||||
|
paymentConfig: 'የክፍያ ውቅረት',
|
||||||
|
analytics: 'ትንታኔ',
|
||||||
|
medicalVerification: 'የህክምና ማረጋገጫ',
|
||||||
|
locations: 'አካባቢዎች',
|
||||||
|
configuration: 'ውቅረት',
|
||||||
profile: 'መገለጫ',
|
profile: 'መገለጫ',
|
||||||
|
questions: 'ጥያቄዎች',
|
||||||
|
exams: 'ፈተናዎች',
|
||||||
|
examResults: 'የፈተና ውጤቶች',
|
||||||
collapseSidebar: 'ሰብስብ',
|
collapseSidebar: 'ሰብስብ',
|
||||||
expandSidebar: 'ዘርጋ',
|
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: {
|
location: {
|
||||||
title: 'አካባቢዎች',
|
title: 'አካባቢዎች',
|
||||||
hierarchy: 'የአካባቢ ተዋረድ',
|
hierarchy: 'የአካባቢ ተዋረድ',
|
||||||
@@ -97,9 +247,11 @@ export const am: Translations = {
|
|||||||
addTitle: 'አካባቢ ያክሉ',
|
addTitle: 'አካባቢ ያክሉ',
|
||||||
editTitle: 'አካባቢ ያስተካክሉ',
|
editTitle: 'አካባቢ ያስተካክሉ',
|
||||||
code: 'ኮድ',
|
code: 'ኮድ',
|
||||||
|
name: 'ስም',
|
||||||
nameEn: 'ስም (እንግሊዝኛ)',
|
nameEn: 'ስም (እንግሊዝኛ)',
|
||||||
nameAm: 'ስም (አማርኛ)',
|
nameAm: 'ስም (አማርኛ)',
|
||||||
type: 'አይነት',
|
type: 'አይነት',
|
||||||
|
level: 'ደረጃ',
|
||||||
parent: 'ወላጅ',
|
parent: 'ወላጅ',
|
||||||
selectType: 'አይነት ይምረጡ',
|
selectType: 'አይነት ይምረጡ',
|
||||||
cancel: 'ሰርዝ',
|
cancel: 'ሰርዝ',
|
||||||
@@ -168,6 +320,12 @@ export const am: Translations = {
|
|||||||
title: 'ባለሁለት ደረጃ ማረጋገጫ',
|
title: 'ባለሁለት ደረጃ ማረጋገጫ',
|
||||||
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
|
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
|
||||||
},
|
},
|
||||||
|
layout: {
|
||||||
|
title: 'አቀማመጥ',
|
||||||
|
subtitle: 'የማውጫ አቀማመጥ እንዴት እንደሚታይ ይምረጡ።',
|
||||||
|
top: 'የላይኛው ትሮች',
|
||||||
|
sidebar: 'የጎን አሞሌ',
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'የኢሜይል ማሳወቂያዎች',
|
title: 'የኢሜይል ማሳወቂያዎች',
|
||||||
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
|
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
|
||||||
@@ -206,4 +364,221 @@ export const am: Translations = {
|
|||||||
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
|
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',
|
menu: 'MENU',
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
userManagement: 'User Management',
|
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',
|
profile: 'Profile',
|
||||||
|
questions: 'Questions',
|
||||||
|
exams: 'Examinations',
|
||||||
|
examResults: 'Exam Results',
|
||||||
collapseSidebar: 'Collapse',
|
collapseSidebar: 'Collapse',
|
||||||
expandSidebar: 'Expand sidebar',
|
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: {
|
location: {
|
||||||
title: 'Locations',
|
title: 'Locations',
|
||||||
hierarchy: 'Location Hierarchy',
|
hierarchy: 'Location Hierarchy',
|
||||||
@@ -95,9 +245,11 @@ export const en = {
|
|||||||
addTitle: 'Add Location',
|
addTitle: 'Add Location',
|
||||||
editTitle: 'Edit Location',
|
editTitle: 'Edit Location',
|
||||||
code: 'Code',
|
code: 'Code',
|
||||||
|
name: 'Name',
|
||||||
nameEn: 'Name (English)',
|
nameEn: 'Name (English)',
|
||||||
nameAm: 'Name (Amharic)',
|
nameAm: 'Name (Amharic)',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
|
level: 'Level',
|
||||||
parent: 'Parent',
|
parent: 'Parent',
|
||||||
selectType: 'Select type',
|
selectType: 'Select type',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
@@ -166,6 +318,12 @@ export const en = {
|
|||||||
title: 'Two-step verification',
|
title: 'Two-step verification',
|
||||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
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: {
|
notifications: {
|
||||||
title: 'Email notifications',
|
title: 'Email notifications',
|
||||||
desc: 'Receive updates about your account activity by email.',
|
desc: 'Receive updates about your account activity by email.',
|
||||||
@@ -205,6 +363,223 @@ export const en = {
|
|||||||
passwordMismatch: 'Passwords do not match',
|
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;
|
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 { AppShell, rem } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { logout } from '@ema-platform/auth';
|
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 {
|
import {
|
||||||
|
IconBook2,
|
||||||
|
IconChartBar,
|
||||||
|
IconCreditCard,
|
||||||
|
IconFileDescription,
|
||||||
|
IconHeart,
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconUsers,
|
IconShieldCheck,
|
||||||
|
IconRubberStamp,
|
||||||
|
IconSettings,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconMap,
|
IconUsers,
|
||||||
|
IconUserShield,
|
||||||
|
IconQuestionMark,
|
||||||
|
IconClipboardList,
|
||||||
|
IconReport,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
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[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
|
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||||
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
|
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||||
{ to: '/locations', label: 'Locations', icon: IconMap },
|
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||||
{ to: '/profile', label: 'Profile', icon: IconUser },
|
{ 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;
|
const HEADER_HEIGHT = 116;
|
||||||
@@ -38,7 +53,9 @@ export function BackofficeLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [opened, { toggle: toggleNav }] = useDisclosure();
|
const [opened, { toggle: toggleNav }] = useDisclosure();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || '';
|
const displayName = user?.name?.en || user?.username || '';
|
||||||
const initials = displayName
|
const initials = displayName
|
||||||
@@ -62,7 +79,7 @@ export function BackofficeLayout() {
|
|||||||
|
|
||||||
const go = (item: NavItem) => {
|
const go = (item: NavItem) => {
|
||||||
if (item.soon) {
|
if (item.soon) {
|
||||||
notify.info(`${item.label} — coming soon.`);
|
notify.info(`${t(item.label)} — coming soon.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
@@ -70,9 +87,20 @@ export function BackofficeLayout() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleCollapse = useCallback(() => {
|
||||||
|
setCollapsed((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSidebar = layoutMode === 'sidebar';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<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"
|
padding="lg"
|
||||||
>
|
>
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
@@ -83,11 +111,10 @@ export function BackofficeLayout() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
|
||||||
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
onToggleNav={toggleNav}
|
onToggleNav={toggleNav}
|
||||||
onToggleSidebar={toggleNav}
|
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||||
navOpened={opened}
|
navOpened={opened}
|
||||||
breadcrumbs={crumbs}
|
breadcrumbs={crumbs}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
@@ -98,63 +125,87 @@ export function BackofficeLayout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legacy-style tab bar — matching UM AppMenuTabs look */}
|
{!isSidebar && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
display: 'flex',
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
transition: 'width 200ms ease',
|
||||||
gap: rem(2),
|
background: 'var(--mantine-color-body)',
|
||||||
padding: '0 32px',
|
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||||
height: 42,
|
|
||||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
|
||||||
overflowX: 'auto',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
<AppSidebar
|
||||||
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
|
navItems={NAV_ITEMS}
|
||||||
const ItemIcon = item.icon;
|
collapsed={collapsed}
|
||||||
return (
|
activePath={location.pathname}
|
||||||
<button
|
onToggleCollapse={handleToggleCollapse}
|
||||||
key={item.to}
|
onNavigate={go}
|
||||||
onClick={() => go(item)}
|
brandName={t('app.name')}
|
||||||
style={{
|
brandSubtitle={t('app.authority')}
|
||||||
display: 'flex',
|
/>
|
||||||
alignItems: 'center',
|
</AppShell.Navbar>
|
||||||
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>
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
<div key={location.pathname} className="ema-page-enter">
|
<div key={location.pathname} className="ema-page-enter">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -12,9 +12,24 @@ import { AuthLayout } from '../layouts/AuthLayout';
|
|||||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
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 { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||||
|
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
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([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -25,7 +40,9 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
{ 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 />,
|
element: <ProtectedRoute />,
|
||||||
children: [
|
children: [
|
||||||
@@ -35,7 +52,22 @@ const router = createBrowserRouter([
|
|||||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', element: <DashboardPage /> },
|
||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
|
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||||
{ path: 'locations', element: <LocationPage /> },
|
{ 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,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
} from '@ema-platform/auth';
|
} 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');
|
configureAuthStorage('ema-backoffice');
|
||||||
|
|
||||||
const preloadedAuth = (() => {
|
const preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
const user = authStorage.getUser<AuthUser>();
|
const user = authStorage.getUser<AuthUser>();
|
||||||
|
const profile = authStorage.getProfile<CurrentProfile>();
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
return { token, user, isAuthenticated: true };
|
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
@@ -25,6 +27,7 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
signup: signupReducer,
|
signup: signupReducer,
|
||||||
|
preferences: preferencesReducer,
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
[baseApi.reducerPath]: baseApi.reducer,
|
||||||
},
|
},
|
||||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
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/i18n/config';
|
||||||
import { App } from './app/app';
|
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__ = {
|
window.__USER_MANAGEMENT_BRANDING__ = {
|
||||||
appName: 'Ethiopian Maritime Licence',
|
appName: 'Ethiopian Maritime Licence',
|
||||||
organizationName: 'Ethiopian Maritime Authority',
|
organizationName: 'Ethiopian Maritime Authority',
|
||||||
|
|||||||
@@ -2,20 +2,6 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
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({
|
export default defineConfig({
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||||
@@ -24,7 +10,7 @@ export default defineConfig({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
preview: { port: 4201, host: 'localhost' },
|
preview: { port: 4201, host: 'localhost' },
|
||||||
plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
|
plugins: [react(), nxViteTsPaths()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { configureIam } from '@tria-plc/iamui-common';
|
|
||||||
import { AppProviders } from './providers/AppProviders';
|
import { AppProviders } from './providers/AppProviders';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
|
|
||||||
// IAM module configuration (used by the isolated /users admin route).
|
|
||||||
configureIam({ apiUrl: 'http://localhost:3001/api' });
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
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 {
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
|
Progress,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -10,23 +13,59 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
useMantineTheme,
|
useMantineTheme,
|
||||||
|
rem,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconBook2,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
|
IconClipboardList,
|
||||||
|
IconFileCheck,
|
||||||
|
IconHeart,
|
||||||
IconLifebuoy,
|
IconLifebuoy,
|
||||||
IconShip,
|
IconShip,
|
||||||
|
IconShieldCheck,
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
|
IconBell,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const theme = useMantineTheme();
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* ---- Hero banner ------------------------------------------- */}
|
{/* Hero */}
|
||||||
<Paper
|
<Paper
|
||||||
radius="lg"
|
radius="lg"
|
||||||
p="xl"
|
p="xl"
|
||||||
@@ -36,11 +75,11 @@ export function DashboardPage() {
|
|||||||
<Stack gap="md" maw={560}>
|
<Stack gap="md" maw={560}>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Title order={2} c="white" fz={26}>
|
<Title order={2} c="white" fz={26}>
|
||||||
Welcome to the EMA Portal
|
Welcome to the EMA Seafarer Portal
|
||||||
</Title>
|
</Title>
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||||
Manage your seafarer profile, submit applications, and track your
|
Manage your seafarer profile, track certificates, apply for your Seaman Book
|
||||||
maritime credentials all in one place.
|
and monitor your maritime credentials — all in one place.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -55,54 +94,144 @@ export function DashboardPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</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">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
|
{/* Profile completeness */}
|
||||||
<Paper withBorder radius="lg" p="lg">
|
<Paper withBorder radius="lg" p="lg">
|
||||||
<Title order={4} mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
Quick actions
|
<Text fw={700}>Registration Checklist</Text>
|
||||||
</Title>
|
<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">
|
<Stack gap="xs">
|
||||||
<QuickAction
|
{PROFILE_STEPS.map((step) => (
|
||||||
icon={IconUserPlus}
|
<Group key={step.label} gap="xs">
|
||||||
color="emaPrimary"
|
<ThemeIcon
|
||||||
label="Register a new seafarer"
|
size={20}
|
||||||
onClick={() => navigate('/seafarer-registration')}
|
radius="xl"
|
||||||
/>
|
variant={step.done ? 'filled' : 'light'}
|
||||||
<QuickAction
|
color={step.done ? 'teal' : 'gray'}
|
||||||
icon={IconLifebuoy}
|
>
|
||||||
color="orange"
|
<IconFileCheck size={12} />
|
||||||
label="Contact support"
|
</ThemeIcon>
|
||||||
onClick={() => navigate('/support')}
|
<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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</SimpleGrid>
|
</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>
|
</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({
|
function QuickAction({
|
||||||
icon: ActionIconCmp,
|
icon: ActionIconCmp,
|
||||||
color,
|
color,
|
||||||
label,
|
label,
|
||||||
|
sub,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
icon: Icon;
|
icon: Icon;
|
||||||
color: string;
|
color: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
sub: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<UnstyledButton onClick={onClick}>
|
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
|
||||||
<Card padding="xs" radius="md" bg="var(--mantine-color-default-hover)">
|
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon variant="light" color={color} size={38} radius="md">
|
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
|
||||||
<ActionIconCmp size={19} />
|
<ActionIconCmp size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="sm" fw={500} flex={1}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{label}
|
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
|
||||||
</Text>
|
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
|
||||||
<IconChevronRight size={16} style={{ opacity: 0.45 }} />
|
</div>
|
||||||
|
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
</UnstyledButton>
|
</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 {
|
interface LocationPickerProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: (locationId: string | null) => void;
|
onChange?: (locationId: string | null) => void;
|
||||||
|
onChainChange?: (chain: Location[]) => void;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationPicker({ value, onChange, required }: LocationPickerProps) {
|
export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||||
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
|
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;
|
current = current.parentId ? locMap.get(current.parentId) : undefined;
|
||||||
}
|
}
|
||||||
setSelectedChain(chain);
|
setSelectedChain(chain);
|
||||||
}, [value, locMap]);
|
onChainChange?.(chain);
|
||||||
|
}, [value, locMap, onChainChange]);
|
||||||
|
|
||||||
const currentLevelChildren = useMemo(() => {
|
const currentLevelChildren = useMemo(() => {
|
||||||
const parentId =
|
const parentId =
|
||||||
@@ -89,7 +91,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
const newChain = selectedChain.slice(0, -1);
|
const newChain = selectedChain.slice(0, -1);
|
||||||
setSelectedChain(newChain);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +103,10 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
|
|||||||
newChain.push(loc);
|
newChain.push(loc);
|
||||||
setSelectedChain(newChain);
|
setSelectedChain(newChain);
|
||||||
|
|
||||||
onChange(id);
|
onChange?.(id);
|
||||||
|
onChainChange?.(newChain);
|
||||||
},
|
},
|
||||||
[selectedChain, locMap, onChange, depth],
|
[selectedChain, locMap, onChange, onChainChange, depth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildOptions = (levelIdx: number) => {
|
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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Center,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
|
IconMapPin,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconPhone,
|
IconPhone,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -42,10 +45,21 @@ import { z } from 'zod';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader } from '@ema-platform/ui';
|
import { notify, PageHeader } from '@ema-platform/ui';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
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 { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
import { setUser } from '@ema-platform/auth';
|
|
||||||
import type { AuthUser } 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';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string, fallback: string) {
|
function getInitials(name: string, fallback: string) {
|
||||||
@@ -56,7 +70,6 @@ function getInitials(name: string, fallback: string) {
|
|||||||
return letters.toUpperCase();
|
return letters.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0–4 rough strength score used by the meter on the security tab. */
|
|
||||||
function passwordScore(pw: string) {
|
function passwordScore(pw: string) {
|
||||||
if (!pw) return 0;
|
if (!pw) return 0;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
@@ -71,21 +84,121 @@ export function ProfilePage() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
|
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
const [passwordTrigger] = useApiMutation<unknown>();
|
const [passwordTrigger] = useApiMutation<unknown>();
|
||||||
|
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||||
|
|
||||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
const [isSavingPassword, setIsSavingPassword] = 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 [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||||
|
|
||||||
// Load the latest profile from the server on mount so the form always
|
// ---- Profession list (for Profile tab) ----
|
||||||
// reflects the current account information (the cached user may be stale).
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
meTrigger({ url: '/auth/me', method: 'GET' })
|
meTrigger({ url: '/auth/me', method: 'GET' })
|
||||||
@@ -93,37 +206,28 @@ export function ProfilePage() {
|
|||||||
.then((me) => {
|
.then((me) => {
|
||||||
if (active) dispatch(setUser(me));
|
if (active) dispatch(setUser(me));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {});
|
||||||
/* fall back to the cached user already in the store */
|
return () => { active = false; };
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
// meTrigger/dispatch are stable; run once on mount.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- Profile form ----
|
// ---- Personal form (auth user data) ----
|
||||||
const profileSchema = z.object({
|
const personalSchema = z.object({
|
||||||
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||||
username: z
|
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||||
.string()
|
|
||||||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
|
||||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||||
phoneNumber: z
|
phoneNumber: z.string().min(1, { message: t('profile.validation.phoneRequired') }),
|
||||||
.string()
|
|
||||||
.min(1, { message: t('profile.validation.phoneRequired') }),
|
|
||||||
});
|
});
|
||||||
type ProfileValues = z.infer<typeof profileSchema>;
|
type PersonalValues = z.infer<typeof personalSchema>;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: registerProfile,
|
register: registerPersonal,
|
||||||
handleSubmit: handleProfileSubmit,
|
handleSubmit: handlePersonalSubmit,
|
||||||
reset: resetProfile,
|
reset: resetPersonal,
|
||||||
formState: { errors: profileErrors },
|
formState: { errors: personalErrors },
|
||||||
} = useForm<ProfileValues>({
|
} = useForm<PersonalValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(personalSchema),
|
||||||
values: {
|
values: {
|
||||||
nameEn: user?.name?.en ?? '',
|
nameEn: user?.name?.en ?? '',
|
||||||
nameAm: user?.name?.am ?? '',
|
nameAm: user?.name?.am ?? '',
|
||||||
@@ -133,7 +237,7 @@ export function ProfilePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSaveProfile = async (values: ProfileValues) => {
|
const onSavePersonal = async (values: PersonalValues) => {
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
await updateTrigger({
|
await updateTrigger({
|
||||||
@@ -147,7 +251,6 @@ export function ProfilePage() {
|
|||||||
},
|
},
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
// Refresh the cached user so the rest of the app stays in sync.
|
|
||||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||||
dispatch(setUser(me));
|
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 ----
|
// ---- Password form ----
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
oldPassword: z
|
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
.string()
|
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
||||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
confirmPassword: z.string().min(8, { 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, {
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
message: t('profile.validation.passwordMismatch'),
|
message: t('profile.validation.passwordMismatch'),
|
||||||
@@ -214,21 +376,13 @@ export function ProfilePage() {
|
|||||||
const displayName = user?.name?.en || user?.username || '';
|
const displayName = user?.name?.en || user?.username || '';
|
||||||
const score = passwordScore(watchPassword('newPassword'));
|
const score = passwordScore(watchPassword('newPassword'));
|
||||||
const strengthLabels = [
|
const strengthLabels = [
|
||||||
'',
|
'', t('profile.strength.weak'), t('profile.strength.fair'),
|
||||||
t('profile.strength.weak'),
|
t('profile.strength.good'), t('profile.strength.strong'),
|
||||||
t('profile.strength.fair'),
|
|
||||||
t('profile.strength.good'),
|
|
||||||
t('profile.strength.strong'),
|
|
||||||
];
|
];
|
||||||
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
||||||
|
|
||||||
const flags: Record<AppLanguage, string> = { en: '🇬🇧', am: '🇪🇹' };
|
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: 'light', label: t('profile.appearance.light'), icon: IconSun },
|
||||||
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
|
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
|
||||||
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
|
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
|
||||||
@@ -297,13 +451,19 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="profile"
|
defaultValue="personal"
|
||||||
variant="pills"
|
variant="pills"
|
||||||
classNames={{ list: classes.list, tab: classes.tab }}
|
classNames={{ list: classes.list, tab: classes.tab }}
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
|
||||||
{t('profile.tabs.profile')}
|
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>
|
||||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||||
{t('profile.tabs.security')}
|
{t('profile.tabs.security')}
|
||||||
@@ -313,10 +473,10 @@ export function ProfilePage() {
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* ---- Profile ---- */}
|
{/* ---- Personal (auth user data) ---- */}
|
||||||
<Tabs.Panel value="profile" pt="md">
|
<Tabs.Panel value="personal" pt="md">
|
||||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
<form onSubmit={handlePersonalSubmit(onSavePersonal)}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Title order={5}>{t('profile.personal')}</Title>
|
<Title order={5}>{t('profile.personal')}</Title>
|
||||||
@@ -327,14 +487,14 @@ export function ProfilePage() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={t('profile.fields.fullNameEn')}
|
label={t('profile.fields.fullNameEn')}
|
||||||
leftSection={<IconUser size={18} />}
|
leftSection={<IconUser size={18} />}
|
||||||
error={profileErrors.nameEn?.message}
|
error={personalErrors.nameEn?.message}
|
||||||
{...registerProfile('nameEn')}
|
{...registerPersonal('nameEn')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('profile.fields.fullNameAm')}
|
label={t('profile.fields.fullNameAm')}
|
||||||
leftSection={<IconUser size={18} />}
|
leftSection={<IconUser size={18} />}
|
||||||
error={profileErrors.nameAm?.message}
|
error={personalErrors.nameAm?.message}
|
||||||
{...registerProfile('nameAm')}
|
{...registerPersonal('nameAm')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('profile.fields.username')}
|
label={t('profile.fields.username')}
|
||||||
@@ -342,8 +502,8 @@ export function ProfilePage() {
|
|||||||
readOnly
|
readOnly
|
||||||
variant="filled"
|
variant="filled"
|
||||||
leftSection={<IconAt size={18} />}
|
leftSection={<IconAt size={18} />}
|
||||||
error={profileErrors.username?.message}
|
error={personalErrors.username?.message}
|
||||||
{...registerProfile('username')}
|
{...registerPersonal('username')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,14 +518,14 @@ export function ProfilePage() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={t('profile.fields.email')}
|
label={t('profile.fields.email')}
|
||||||
leftSection={<IconMail size={18} />}
|
leftSection={<IconMail size={18} />}
|
||||||
error={profileErrors.email?.message}
|
error={personalErrors.email?.message}
|
||||||
{...registerProfile('email')}
|
{...registerPersonal('email')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('profile.fields.phone')}
|
label={t('profile.fields.phone')}
|
||||||
leftSection={<IconPhone size={18} />}
|
leftSection={<IconPhone size={18} />}
|
||||||
error={profileErrors.phoneNumber?.message}
|
error={personalErrors.phoneNumber?.message}
|
||||||
{...registerProfile('phoneNumber')}
|
{...registerPersonal('phoneNumber')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,7 +534,7 @@ export function ProfilePage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => resetProfile()}
|
onClick={() => resetPersonal()}
|
||||||
>
|
>
|
||||||
{t('profile.cancel')}
|
{t('profile.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -391,6 +551,90 @@ export function ProfilePage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Tabs.Panel>
|
</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 ---- */}
|
{/* ---- Security ---- */}
|
||||||
<Tabs.Panel value="security" pt="md">
|
<Tabs.Panel value="security" pt="md">
|
||||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||||
import { BilingualInput } from '../../../components/BilingualInput';
|
import type { BilingualValue } from '@ema-platform/ui';
|
||||||
import type { BilingualValue } from '../../../components/BilingualInput';
|
|
||||||
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
|
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
|
||||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
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: {
|
nav: {
|
||||||
dashboard: 'ዳሽቦርድ',
|
dashboard: 'ዳሽቦርድ',
|
||||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||||
|
myApplication: 'ማመልከቻዬ',
|
||||||
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
|
endorsements: 'ማረጋገጫዎች',
|
||||||
|
documents: 'ሰነዶቼ',
|
||||||
|
notifications: 'ማሳወቂያዎች',
|
||||||
profile: 'መገለጫ',
|
profile: 'መገለጫ',
|
||||||
support: 'እገዛና ድጋፍ',
|
support: 'እገዛና ድጋፍ',
|
||||||
collapseSidebar: 'ሰብስብ',
|
collapseSidebar: 'ሰብስብ',
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export const en = {
|
|||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
myApplication: 'My Application',
|
||||||
|
certificates: 'Certificates',
|
||||||
|
endorsements: 'Endorsements',
|
||||||
|
documents: 'My Documents',
|
||||||
|
notifications: 'Notifications',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
support: 'Help & Support',
|
support: 'Help & Support',
|
||||||
collapseSidebar: 'Collapse',
|
collapseSidebar: 'Collapse',
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { AppShell } from '@mantine/core';
|
import { AppShell } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLayoutDashboard,
|
IconBell,
|
||||||
IconLifebuoy,
|
IconFolderOpen,
|
||||||
|
IconHeadset,
|
||||||
|
IconHome2,
|
||||||
IconList,
|
IconList,
|
||||||
IconUser,
|
IconRubberStamp,
|
||||||
|
IconSend,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -16,16 +21,27 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
|||||||
import { useAppSelector } from '../store/hooks';
|
import { useAppSelector } from '../store/hooks';
|
||||||
|
|
||||||
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
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: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
|
||||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
|
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
|
||||||
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconLifebuoy },
|
{ 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 }> = {
|
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||||
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
|
'/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() {
|
export function PortalLayout() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from '@tria-plc/iamui-common';
|
|
||||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { store } from '../store';
|
import { store } from '../store';
|
||||||
@@ -16,19 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthConfigProvider
|
||||||
<AuthConfigProvider
|
value={{
|
||||||
value={{
|
appName: 'Portal',
|
||||||
appName: 'Portal',
|
storagePrefix: 'ema-portal',
|
||||||
storagePrefix: 'ema-portal',
|
loginRedirectPath: '/dashboard',
|
||||||
loginRedirectPath: '/dashboard',
|
enableSignup: true,
|
||||||
enableSignup: true,
|
enableForgotPassword: true,
|
||||||
enableForgotPassword: true,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
</AuthConfigProvider>
|
||||||
</AuthConfigProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { i18n } from './i18n/config';
|
import { i18n } from './i18n/config';
|
||||||
import { PortalLayout } from './layouts/PortalLayout';
|
import { PortalLayout } from './layouts/PortalLayout';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { ProfileGuard } from './components/ProfileGuard';
|
||||||
|
|
||||||
// Auth (standalone pages, no portal chrome)
|
// Auth (standalone pages, no portal chrome)
|
||||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
// Profile setup
|
||||||
|
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
|
||||||
|
|
||||||
// Portal feature pages
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
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 { SeafarerRegistryPage } from './features/seafarer/pages/SeafarerRegistryPage';
|
||||||
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
|
import { SeafarerProfilePage } from './features/seafarer/pages/SeafarerProfilePage';
|
||||||
|
|
||||||
// IAM (admin user management) — kept reachable but isolated under its own
|
// Phase 1 pages
|
||||||
// provider so it does not depend on the portal's provider tree.
|
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
||||||
import {
|
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
|
||||||
AppProviders as IamProviders,
|
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
|
||||||
UserManagementLayout,
|
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
|
||||||
UserManagementPage,
|
|
||||||
} from '@tria-plc/iamui-common';
|
|
||||||
|
|
||||||
function IsolatedIam({ children }: { children: ReactNode }) {
|
// Phase 2 — CoC / CoP
|
||||||
return <IamProviders>{children}</IamProviders>;
|
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([
|
export const router = createBrowserRouter([
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
@@ -42,38 +46,49 @@ export const router = createBrowserRouter([
|
|||||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
|
||||||
|
path: '/profile-setup',
|
||||||
|
},
|
||||||
|
|
||||||
// Portal — protected
|
// Portal — protected
|
||||||
{
|
{
|
||||||
element: (
|
element: (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<I18nextProvider i18n={i18n}>
|
<ProfileGuard>
|
||||||
<PortalLayout />
|
<I18nextProvider i18n={i18n}>
|
||||||
</I18nextProvider>
|
<PortalLayout />
|
||||||
|
</I18nextProvider>
|
||||||
|
</ProfileGuard>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
|
|
||||||
|
// Seafarer
|
||||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||||
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
{ path: '/seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||||
{ path: '/seafarer-registry/:id', element: <SeafarerProfilePage /> },
|
{ 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: '/profile', element: <ProfilePage /> },
|
||||||
{ path: '/support', element: <SupportPage /> },
|
{ 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 /> },
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ import {
|
|||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
} from '@ema-platform/auth';
|
} from '@ema-platform/auth';
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||||
|
|
||||||
configureAuthStorage('ema-portal');
|
configureAuthStorage('ema-portal');
|
||||||
|
|
||||||
const preloadedAuth = (() => {
|
const preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
const user = authStorage.getUser<AuthUser>();
|
const user = authStorage.getUser<AuthUser>();
|
||||||
|
const profile = authStorage.getProfile<CurrentProfile>();
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
return { token, user, isAuthenticated: true };
|
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import '@tria-plc/iamui-common/styles.css';
|
|
||||||
import './app/theme/portal.css';
|
import './app/theme/portal.css';
|
||||||
|
|
||||||
import './app/i18n/config';
|
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 { SignupPage } from './lib/pages/SignupPage';
|
||||||
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
|
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
|
||||||
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
|
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 { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
|
||||||
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
|
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
|
||||||
export { refreshAccessToken } from './lib/utils/refresh-token';
|
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 { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
@@ -35,6 +36,7 @@ export function ForgotPasswordPage() {
|
|||||||
const { appName } = useAuthConfig();
|
const { appName } = useAuthConfig();
|
||||||
const [forgotTrigger, { isLoading }] = useApiMutation();
|
const [forgotTrigger, { isLoading }] = useApiMutation();
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -56,8 +58,11 @@ export function ForgotPasswordPage() {
|
|||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
try {
|
try {
|
||||||
await sendResetLink(values.email);
|
await sendResetLink(values.email);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
const msg =
|
||||||
|
(err as { data?: { message?: string } })?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
setServerError(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -67,8 +72,11 @@ export function ForgotPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
await sendResetLink(sentTo);
|
await sendResetLink(sentTo);
|
||||||
notify.success('Reset link sent again');
|
notify.success('Reset link sent again');
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
const msg =
|
||||||
|
(err as { data?: { message?: string } })?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
setServerError(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -160,6 +168,12 @@ export function ForgotPasswordPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{serverError && (
|
||||||
|
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -25,9 +26,10 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { AuthShell } from '../components/AuthShell';
|
import { AuthShell } from '../components/AuthShell';
|
||||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice';
|
||||||
import type { LoginPayload, AuthUser } from '../types/auth.types';
|
import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types';
|
||||||
import { useAuthConfig } from '../AuthConfig';
|
import { useAuthConfig } from '../AuthConfig';
|
||||||
|
import { authStorage } from '../utils/auth-storage';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email({ message: 'Enter a valid email' }),
|
email: z.string().email({ message: 'Enter a valid email' }),
|
||||||
@@ -43,8 +45,10 @@ export function LoginPage() {
|
|||||||
useAuthConfig();
|
useAuthConfig();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(true);
|
const [rememberMe, setRememberMe] = useState(true);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
|
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -70,15 +74,46 @@ export function LoginPage() {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
dispatch(setUser(me));
|
dispatch(setUser(me));
|
||||||
|
|
||||||
if (me.isPhoneNumberVerified) {
|
let hasProfile = false;
|
||||||
navigate(loginRedirectPath);
|
try {
|
||||||
} else {
|
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
|
||||||
navigate('/otp-verify', {
|
const result = await profileCheckTrigger({
|
||||||
state: { email: me.email, phoneNumber: me.phoneNumber },
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -96,6 +131,12 @@ export function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{serverError && (
|
||||||
|
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
@@ -37,14 +38,16 @@ export function OTPVerificationPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { loginRedirectPath } = useAuthConfig();
|
const { loginRedirectPath } = useAuthConfig();
|
||||||
const state = location.state as
|
const state = location.state as
|
||||||
| { email?: string; phoneNumber?: string }
|
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
|
||||||
| null;
|
| null;
|
||||||
const email = state?.email ?? '';
|
const email = state?.email ?? '';
|
||||||
const phoneNumber = state?.phoneNumber ?? '';
|
const phoneNumber = state?.phoneNumber ?? '';
|
||||||
|
const needsProfile = state?.needsProfile ?? false;
|
||||||
|
|
||||||
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
|
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
|
||||||
const [resendTrigger, { isLoading: resending }] = useApiMutation();
|
const [resendTrigger, { isLoading: resending }] = useApiMutation();
|
||||||
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
|
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -70,9 +73,12 @@ export function OTPVerificationPage() {
|
|||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
notify.success('Phone number verified successfully');
|
notify.success('Phone number verified successfully');
|
||||||
navigate(loginRedirectPath);
|
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
const msg =
|
||||||
|
(err as { data?: { message?: string } })?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
setServerError(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,15 +87,19 @@ export function OTPVerificationPage() {
|
|||||||
if (secondsLeft > 0 || resending) return;
|
if (secondsLeft > 0 || resending) return;
|
||||||
try {
|
try {
|
||||||
await resendTrigger({
|
await resendTrigger({
|
||||||
url: '/auth/resend-otp',
|
url: '/auth/generate-verification-code',
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
body: { email },
|
body: { email, phoneNumber, type: 'verify-phone-number' },
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
notify.success('Verification code resent to your email');
|
notify.success('Verification code resent to your email');
|
||||||
setSecondsLeft(RESEND_SECONDS);
|
setSecondsLeft(RESEND_SECONDS);
|
||||||
} catch (err) {
|
setServerError(null);
|
||||||
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);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -117,6 +127,12 @@ export function OTPVerificationPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{serverError && (
|
||||||
|
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Controller
|
<Controller
|
||||||
@@ -126,8 +142,8 @@ export function OTPVerificationPage() {
|
|||||||
<Stack gap={6} align="center">
|
<Stack gap={6} align="center">
|
||||||
<PinInput
|
<PinInput
|
||||||
length={CODE_LENGTH}
|
length={CODE_LENGTH}
|
||||||
type="number"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="text"
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
size="md"
|
size="md"
|
||||||
gap="sm"
|
gap="sm"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -27,7 +28,8 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { AuthShell } from '../components/AuthShell';
|
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';
|
import { useAuthConfig } from '../AuthConfig';
|
||||||
|
|
||||||
const schema = z
|
const schema = z
|
||||||
@@ -66,11 +68,13 @@ export function SignupPage() {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { appName, loginRedirectPath } = useAuthConfig();
|
const { appName, loginRedirectPath } = useAuthConfig();
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
|
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
|
||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
isPhoneNumberVerified: boolean;
|
isPhoneNumberVerified: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -107,15 +111,25 @@ export function SignupPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||||
|
dispatch(setUser(me));
|
||||||
|
|
||||||
if (data.isPhoneNumberVerified) {
|
if (data.isPhoneNumberVerified) {
|
||||||
navigate(loginRedirectPath);
|
navigate('/profile-setup');
|
||||||
} else {
|
} else {
|
||||||
navigate('/otp-verify', {
|
navigate('/otp-verify', {
|
||||||
state: { email: values.email, phoneNumber: values.phoneNumber },
|
state: {
|
||||||
|
email: values.email,
|
||||||
|
phoneNumber: values.phoneNumber,
|
||||||
|
needsProfile: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
const msg =
|
||||||
|
(err as { data?: { message?: string } })?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : 'Something went wrong');
|
||||||
|
setServerError(msg);
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,6 +149,12 @@ export function SignupPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{serverError && (
|
||||||
|
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
|
||||||
|
{serverError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
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';
|
import { authStorage } from '../utils/auth-storage';
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
currentProfile: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
@@ -22,23 +23,36 @@ const authSlice = createSlice({
|
|||||||
state.user = action.payload;
|
state.user = action.payload;
|
||||||
authStorage.setUser(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) {
|
logout(state) {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
|
state.currentProfile = null;
|
||||||
authStorage.clear();
|
authStorage.clear();
|
||||||
},
|
},
|
||||||
hydrateAuth(state) {
|
hydrateAuth(state) {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
const user = authStorage.getUser<AuthUser>();
|
const user = authStorage.getUser<AuthUser>();
|
||||||
|
const profile = authStorage.getProfile<CurrentProfile>();
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
state.token = token;
|
state.token = token;
|
||||||
state.user = user;
|
state.user = user;
|
||||||
state.isAuthenticated = true;
|
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;
|
export const authReducer = authSlice.reducer;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface AuthState {
|
|||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
currentProfile: CurrentProfile | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
@@ -26,3 +27,57 @@ export interface LoginPayload {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
isPhoneNumberVerified: boolean;
|
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)),
|
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: () => {
|
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),
|
localStorage.removeItem(k),
|
||||||
);
|
);
|
||||||
document.cookie =
|
document.cookie =
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './lib/input/BilingualInput';
|
||||||
export * from './lib/feedback/ConfirmModal';
|
export * from './lib/feedback/ConfirmModal';
|
||||||
export * from './lib/feedback/ApiErrorAlert';
|
export * from './lib/feedback/ApiErrorAlert';
|
||||||
export * from './lib/feedback/notify';
|
export * from './lib/feedback/notify';
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ export interface BilingualValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BilingualInputProps
|
interface BilingualInputProps
|
||||||
extends Omit<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
|
extends Omit<TextInputProps, 'value' | 'onChange' | 'placeholder' | 'rightSection' | 'rightSectionWidth'> {
|
||||||
value: BilingualValue;
|
value: BilingualValue;
|
||||||
onChange: (value: BilingualValue) => void;
|
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({
|
export function BilingualInput({
|
||||||
@@ -33,7 +44,7 @@ export function BilingualInput({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={label}
|
label={label}
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
|
placeholder={resolvePlaceholder(placeholder, lang)}
|
||||||
value={value[lang]}
|
value={value[lang]}
|
||||||
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
|
||||||
rightSection={
|
rightSection={
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Box,
|
||||||
Burger,
|
Burger,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
Indicator,
|
||||||
@@ -52,7 +53,7 @@ export function AppHeader({
|
|||||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
{/* Hamburger — styled like user-management Top.tsx */}
|
{/* Hamburger — styled like user-management Top.tsx */}
|
||||||
<UnstyledButton
|
<Box
|
||||||
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -91,7 +92,7 @@ export function AppHeader({
|
|||||||
burger: { '--burger-color': 'currentColor' },
|
burger: { '--burger-color': 'currentColor' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</UnstyledButton>
|
</Box>
|
||||||
|
|
||||||
{/* Breadcrumbs — card container with pill-style crumbs */}
|
{/* Breadcrumbs — card container with pill-style crumbs */}
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AppSidebar({
|
|||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
<Tooltip key={item.label} label={t(item.label)} position="right" withArrow>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => onNavigate(item)}
|
onClick={() => onNavigate(item)}
|
||||||
style={{
|
style={{
|
||||||
@@ -127,7 +127,7 @@ export function AppSidebar({
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.label}
|
key={item.label}
|
||||||
active={active}
|
active={active}
|
||||||
label={item.label}
|
label={t(item.label)}
|
||||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||||
onClick={() => onNavigate(item)}
|
onClick={() => onNavigate(item)}
|
||||||
variant="light"
|
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",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"backoffice": "npm run build:user-management && nx serve @ema-platform/backoffice",
|
"backoffice": "nx serve @ema-platform/backoffice",
|
||||||
"portal": "nx serve @ema-platform/portal",
|
"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:backoffice": "nx build @ema-platform/backoffice",
|
||||||
"build:portal": "nx build @ema-platform/portal",
|
"build:portal": "nx build @ema-platform/portal",
|
||||||
"lint": "nx run-many -t lint",
|
"lint": "nx run-many -t lint",
|
||||||
"test": "nx run-many -t test",
|
"test": "nx run-many -t test",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write ."
|
||||||
"build:user-management": "cd user-management-config && npm run build",
|
|
||||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@daypicker/ethiopic": "^10.0.1",
|
"@daypicker/ethiopic": "^10.0.1",
|
||||||
@@ -27,7 +25,7 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"@tanstack/react-query": "^5.99.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",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
|||||||
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