Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-03 09:26:04 +03:00
133 changed files with 12220 additions and 23161 deletions

View File

@@ -1,255 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Analytics"
description="Reporting is not connected to the backend yet. The Logistics overview shows real licence figures in the meantime."
/>
</Container>
);
}
export default AnalyticsPage;

View File

@@ -1,446 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Application review"
description="This generic review screen is superseded by Licence Applications, which is connected to real data."
/>
</Container>
);
}
export default ApplicationReviewPage;

View File

@@ -0,0 +1,560 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Code,
Container,
Group,
Modal,
NumberInput,
Paper,
ScrollArea,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconDeviceFloppy,
IconEye,
IconPlus,
IconRosetteDiscountCheck,
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
extractErrorMessage,
useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation,
useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery,
useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
type LicenseTemplate,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { authStorage, usePermissions } from '@ema-platform/auth';
import { PERMISSIONS } from '../../../layouts/nav-config';
/** Same resolution the shared RTK Query baseQuery uses. */
const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',
PUBLISHED: 'teal',
ARCHIVED: 'dark',
};
/**
* Where the authority designs the certificate its licensees receive.
*
* The layout used to be a Handlebars file inside the deployed image, so any
* change to the authority's own certificate needed a developer and a release.
* Here it is data: staff author a version, preview the real PDF, and publish.
* Publishing archives the incumbent, so exactly one design is live per licence
* type and previously issued certificates keep the design they were made from.
*/
export function CertificateDesignerPage() {
const { t } = useTranslation();
const { can } = usePermissions();
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null);
const {
data: templates = [],
isLoading,
isError,
error,
refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery();
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [source, setSource] = useState('');
const [name, setName] = useState('');
const [landscape, setLandscape] = useState(true);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const editorRef = useRef<HTMLTextAreaElement>(null);
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
const [validityMonths, setValidityMonths] = useState<number>(12);
// Default to the first licence type so the page is never an empty shell.
useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
}, [licenseTypes, typeId]);
useEffect(() => {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]);
const selected = useMemo(
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
[templates, selectedId],
);
// Pick the live design by default — that is the one staff usually want.
useEffect(() => {
if (!templates.length) {
setSelectedId(null);
return;
}
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
setSelectedId((published ?? templates[0]).id);
}, [templates, selectedId]);
useEffect(() => {
if (!selected) return;
setSource(selected.hbsSource);
setName(selected.name);
setLandscape(selected.pageOptions?.landscape ?? true);
}, [selected]);
const isPublished = selected?.status === 'PUBLISHED';
const dirty =
Boolean(selected) &&
(source !== selected?.hbsSource ||
name !== selected?.name ||
landscape !== (selected?.pageOptions?.landscape ?? true));
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}
/** Inserts a placeholder where the caret is, rather than at the end. */
function insertVariable(key: string) {
const el = editorRef.current;
const token = `{{${key}}}`;
if (!el) {
setSource((prev) => prev + token);
return;
}
const start = el.selectionStart ?? source.length;
const end = el.selectionEnd ?? start;
setSource(source.slice(0, start) + token + source.slice(end));
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
}
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
*/
async function preview() {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
// and calls the API directly — which means spelling out the base URL and
// the bearer token that the shared baseQuery would normally attach.
const token = authStorage.getToken();
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
hbsSource: source,
licenseTypeId: typeId,
pageOptions: { format: 'A4', landscape, printBackground: true },
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
notifications.show({
color: 'red',
title: t('designer.previewFailed', 'Could not render the preview'),
message: extractErrorMessage(err),
});
}
}
return (
<Container size="xl" py="md">
<PageHeader
title={t('designer.title', 'Certificate designer')}
subtitle={t(
'designer.subtitle',
'Design the certificate issued to licence holders, and set how long it stays valid.',
)}
/>
<Group align="flex-end" mb="md" gap="sm">
<Select
label={t('designer.licenceType', 'Licence type')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name?.en ?? type.key,
}))}
value={typeId}
onChange={(value) => {
setTypeId(value);
setSelectedId(null);
}}
w={280}
/>
{/* Validity lives beside the design because it is the other half of
what a certificate promises. */}
<NumberInput
label={t('designer.validityYears', 'Valid for (years)')}
description={t('designer.validityHint', 'Applied when a licence is issued')}
value={Number((validityMonths / 12).toFixed(2))}
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
min={0.5}
max={20}
step={0.5}
decimalScale={1}
w={190}
disabled={!canEdit}
/>
<Tooltip
label={
canEdit
? t('designer.saveValidity', 'Save validity')
: t('designer.noPermission', 'You do not have permission')
}
>
<span>
<Button
variant="light"
loading={savingValidity}
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
onClick={() =>
run(
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
t('designer.validitySaved', 'Validity updated'),
)
}
>
{t('designer.saveValidity', 'Save validity')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
<Button
leftSection={<IconPlus size={16} />}
disabled={!canEdit || !typeId}
onClick={() => {
setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
);
setNewOpen(true);
}}
>
{t('designer.newVersion', 'New version')}
</Button>
</Group>
{isError ? (
<ErrorState
title={t('designer.loadFailed', 'Could not load the designs')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
icon={IconAlertCircle}
/>
) : !isLoading && templates.length === 0 ? (
<EmptyState
title={t('designer.empty', 'No design yet for this licence type')}
description={t(
'designer.emptyBody',
'Certificates currently use the built-in layout. Create a version to take control of it.',
)}
action={
canEdit
? {
label: t('designer.newVersion', 'New version'),
onClick: () => {
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
setNewOpen(true);
},
}
: undefined
}
/>
) : (
<Group align="flex-start" gap="md" wrap="nowrap">
{/* Versions */}
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.versions', 'Versions')}
</Text>
{templates.map((tpl) => (
<Card
key={tpl.id}
withBorder
padding="xs"
onClick={() => setSelectedId(tpl.id)}
style={{
cursor: 'pointer',
borderColor:
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{tpl.name}
</Text>
<Text size="xs" c="dimmed">
v{tpl.version}
</Text>
</div>
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
{tpl.status}
</Badge>
</Group>
</Card>
))}
</Stack>
{/* Editor */}
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Group gap="sm" align="flex-end">
<TextInput
label={t('designer.name', 'Version name')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
disabled={!canEdit || isPublished}
style={{ flex: 1 }}
/>
<Switch
label={t('designer.landscape', 'Landscape')}
checked={landscape}
onChange={(e) => setLandscape(e.currentTarget.checked)}
disabled={!canEdit || isPublished}
/>
</Group>
{isPublished && (
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
<Text size="xs">
{t(
'designer.publishedLocked',
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
)}
</Text>
</Paper>
)}
<Textarea
ref={editorRef}
label={t('designer.source', 'Template (Handlebars + HTML)')}
value={source}
onChange={(e) => setSource(e.currentTarget.value)}
disabled={!canEdit || isPublished}
autosize
minRows={18}
maxRows={30}
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
/>
<Group>
<Button
variant="light"
leftSection={<IconEye size={16} />}
onClick={preview}
disabled={!source.trim()}
>
{t('designer.preview', 'Preview PDF')}
</Button>
<Button
leftSection={<IconDeviceFloppy size={16} />}
loading={saving}
disabled={!canEdit || isPublished || !dirty}
onClick={() =>
run(
() =>
updateTemplate({
id: selected!.id,
name,
hbsSource: source,
pageOptions: { format: 'A4', landscape, printBackground: true },
}).unwrap(),
t('designer.saved', 'Draft saved'),
)
}
>
{t('designer.save', 'Save draft')}
</Button>
<Tooltip
label={
!canPublish
? t('designer.noPublishPermission', 'You cannot publish designs')
: dirty
? t('designer.saveFirst', 'Save your changes first')
: t('designer.publishHint', 'Makes this the live certificate design')
}
>
<span>
<Button
color="teal"
leftSection={<IconRosetteDiscountCheck size={16} />}
loading={publishing}
disabled={!canPublish || isPublished || dirty || !selected}
onClick={() =>
run(
() => publishTemplate(selected!.id).unwrap(),
t('designer.published', 'Design published'),
)
}
>
{t('designer.publish', 'Publish')}
</Button>
</span>
</Tooltip>
<div style={{ flex: 1 }} />
{selected && isPublished && canPublish && (
<Button
variant="subtle"
color="orange"
onClick={() =>
run(
() => archiveTemplate(selected.id).unwrap(),
t('designer.archived', 'Design withdrawn'),
)
}
>
{t('designer.archive', 'Withdraw')}
</Button>
)}
{selected && !isPublished && canEdit && (
<Tooltip label={t('designer.delete', 'Delete draft')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('designer.delete', 'Delete draft')}
onClick={() =>
run(
() => deleteTemplate(selected.id).unwrap(),
t('designer.deleted', 'Draft deleted'),
)
}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
{/* Placeholders */}
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
<Text fw={600} size="sm">
{t('designer.variables', 'Placeholders')}
</Text>
<Text size="xs" c="dimmed">
{t('designer.variablesHint', 'Click to insert at the cursor.')}
</Text>
<ScrollArea.Autosize mah={480} type="hover">
<Stack gap={4}>
{variables.map((variable) => (
<Tooltip key={variable.key} label={variable.label} position="left">
<Button
size="compact-xs"
variant="default"
justify="flex-start"
disabled={!canEdit || isPublished}
onClick={() => insertVariable(variable.key)}
>
<Code fz={10}>{`{{${variable.key}}}`}</Code>
</Button>
</Tooltip>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Group>
)}
<Modal
opened={newOpen}
onClose={() => setNewOpen(false)}
title={t('designer.newVersion', 'New version')}
>
<Stack>
<TextInput
label={t('designer.name', 'Version name')}
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
withAsterisk
/>
<Text size="xs" c="dimmed">
{t(
'designer.newHint',
'Starts from the live design, or the built-in layout if this type has none.',
)}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setNewOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
loading={creating}
disabled={!newName.trim()}
onClick={() =>
run(async () => {
const created = await createTemplate({
licenseTypeId: typeId as string,
name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap();
setSelectedId(created.id);
setNewOpen(false);
}, t('designer.created', 'Draft created'))
}
>
{t('designer.create', 'Create')}
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default CertificateDesignerPage;

View File

@@ -1,338 +1,21 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconBook2,
IconCheck,
IconCircleCheck,
IconClock,
IconEye,
IconFileDescription,
IconSearch,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
export type CoCAppStatus =
| 'Submitted'
| 'Document Review'
| 'TRB Inspection'
| 'TRB Resubmit Required'
| 'TRB Rejected'
| 'Examination Scheduled'
| 'Examination Passed'
| 'Examination Failed'
| 'Certificate Issued';
export interface CoCApp {
id: string;
seafarerId: string;
name: string;
email: string;
mobile: string;
nationality: string;
certType: string;
stcwRef: string;
level: 'Support' | 'Operational' | 'Management';
dept: string;
submitted: string;
paymentStatus: 'Verified' | 'Pending';
status: CoCAppStatus;
examDate: string | null;
examVenue: string | null;
seaServiceFile: string;
trbFile: string;
competencyDocs: { area: string; uploaded: boolean; filename: string }[];
trbInspectionNotes: string;
examNotes: string;
officerRemarks: string;
}
export const STATUS_COLOR: Record<CoCAppStatus, string> = {
'Submitted': 'gray',
'Document Review': 'blue',
'TRB Inspection': 'yellow',
'TRB Resubmit Required': 'orange',
'TRB Rejected': 'red',
'Examination Scheduled': 'indigo',
'Examination Passed': 'teal',
'Examination Failed': 'red',
'Certificate Issued': 'teal',
};
export const MOCK_COC_APPS: CoCApp[] = [
{
id: 'COC-APP-2025-001',
seafarerId: 'SF-2024-0001',
name: 'Abebe Girma',
email: 'abebe.g@email.com',
mobile: '+251 911 234 567',
nationality: 'Ethiopian',
certType: 'Officer in Charge of a Navigational Watch (OICNW)',
stcwRef: 'Reg. II/1 — Code A-II/1',
level: 'Operational',
dept: 'Deck',
submitted: '2025-03-10',
paymentStatus: 'Verified',
status: 'TRB Inspection',
examDate: null,
examVenue: null,
seaServiceFile: 'abebe_sea_service.pdf',
trbFile: 'abebe_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Operational Level', uploaded: true, filename: 'abebe_nav.pdf' },
{ area: 'Cargo Handling & Stowage', uploaded: true, filename: 'abebe_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'abebe_ops.pdf' },
{ area: 'Radio Communication (GMDSS)', uploaded: true, filename: 'abebe_gmdss.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'abebe_brm.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-002',
seafarerId: 'SF-2024-0002',
name: 'Sara Tadesse',
email: 'sara.t@email.com',
mobile: '+251 922 345 678',
nationality: 'Ethiopian',
certType: 'Able Seafarer Deck (AB)',
stcwRef: 'Reg. II/5 — Code A-II/5',
level: 'Support',
dept: 'Deck',
submitted: '2025-03-18',
paymentStatus: 'Verified',
status: 'Document Review',
examDate: null,
examVenue: null,
seaServiceFile: 'sara_sea_service.pdf',
trbFile: '',
competencyDocs: [
{ area: 'Navigation', uploaded: true, filename: 'sara_nav.pdf' },
{ area: 'Cargo Operations', uploaded: true, filename: 'sara_cargo.pdf' },
{ area: 'Ship Operations', uploaded: false, filename: '' },
{ area: 'Safety & Emergency', uploaded: true, filename: 'sara_safety.pdf' },
],
trbInspectionNotes: '',
examNotes: '',
officerRemarks: 'Missing Ship Operations certificate. Requested resubmission.',
},
{
id: 'COC-APP-2025-003',
seafarerId: 'SF-2024-0003',
name: 'Dawit Bekele',
email: 'dawit.b@email.com',
mobile: '+251 933 456 789',
nationality: 'Ethiopian',
certType: 'Officer in Charge of an Engineering Watch (OICEW)',
stcwRef: 'Reg. III/1 — Code A-III/1',
level: 'Operational',
dept: 'Engine',
submitted: '2025-03-25',
paymentStatus: 'Verified',
status: 'Examination Scheduled',
examDate: '2025-05-10',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'dawit_sea_service.pdf',
trbFile: 'dawit_trb.pdf',
competencyDocs: [
{ area: 'Marine Engineering at Operational Level', uploaded: true, filename: 'dawit_eng.pdf' },
{ area: 'Electrical & Control Systems', uploaded: true, filename: 'dawit_elec.pdf' },
{ area: 'Maintenance & Repair', uploaded: true, filename: 'dawit_maint.pdf' },
{ area: 'Controlling Ship Operations', uploaded: true, filename: 'dawit_ops.pdf' },
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'dawit_brm.pdf' },
],
trbInspectionNotes: 'TRB physically inspected on 2025-04-20. All competency entries signed off by approved assessor.',
examNotes: '',
officerRemarks: '',
},
{
id: 'COC-APP-2025-004',
seafarerId: 'SF-2023-0088',
name: 'Tekle Haile',
email: 'tekle.h@email.com',
mobile: '+251 944 567 890',
nationality: 'Ethiopian',
certType: 'Chief Mate — Ships 5003,000 GT',
stcwRef: 'Reg. II/2 — Code A-II/2',
level: 'Management',
dept: 'Deck',
submitted: '2025-04-01',
paymentStatus: 'Verified',
status: 'Examination Passed',
examDate: '2025-05-20',
examVenue: 'EMA HQ — Addis Ababa',
seaServiceFile: 'tekle_sea_service.pdf',
trbFile: 'tekle_trb.pdf',
competencyDocs: [
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
],
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
officerRemarks: '',
},
];
const STATUS_OPTIONS = [
'All', 'Submitted', 'Document Review', 'TRB Inspection',
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
'Examination Passed', 'Examination Failed', 'Certificate Issued',
];
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="CoC / CoP queue"
description="Certificate of Competency review is not connected to the backend yet."
/>
</Container>
);
}
export default CoCQueuePage;

View File

@@ -1,621 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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}
<Container size="lg" py="xl">
<FeatureUnavailable
title="CoC / CoP review"
description="Certificate of Competency review is not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export default CoCReviewPage;

View File

@@ -1,419 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShip,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type LicenseStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Payment Pending'
| 'Payment Confirmed'
| 'Certificate Issued';
export interface CombinedLicenseApplication {
id: string;
companyName: string;
tinNumber: string;
commercialRegNumber: string;
businessAddress: string;
bankName: string;
capitalAmount: number;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
docsComplete: boolean;
}
export const MOCK_COMBINED_APPLICATIONS: CombinedLicenseApplication[] = [
{
id: 'CFS-2024-001',
companyName: 'Blue Nile Shipping & Forwarding PLC',
tinNumber: 'TIN-0098231',
commercialRegNumber: 'CR-902341',
businessAddress: 'Addis Ababa, Bole Sub-city',
bankName: 'Commercial Bank of Ethiopia',
capitalAmount: 2200000,
status: 'Under Evaluation',
submittedDate: '2024-03-14',
approvalDate: null,
expiryDate: null,
remarks: 'Terminal agreement and transit staff certificates under review.',
docsComplete: true,
},
{
id: 'CFS-2024-002',
companyName: 'Red Sea Gateway Logistics Ltd',
tinNumber: 'TIN-0071122',
commercialRegNumber: 'CR-813457',
businessAddress: 'Dire Dawa',
bankName: 'Dashen Bank',
capitalAmount: 1650000,
status: 'Submitted',
submittedDate: '2024-04-05',
approvalDate: null,
expiryDate: null,
remarks: '',
docsComplete: false,
},
{
id: 'CFS-2023-017',
companyName: 'Tana Maritime & Forwarding PLC',
tinNumber: 'TIN-0045690',
commercialRegNumber: 'CR-704128',
businessAddress: 'Addis Ababa, Kirkos Sub-city',
bankName: 'Awash Bank',
capitalAmount: 2500000,
status: 'Certificate Issued',
submittedDate: '2023-10-12',
approvalDate: '2023-11-08',
expiryDate: '2024-11-08',
remarks: 'All requirements verified. Certificate issued.',
docsComplete: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Certificate Issued': 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: CombinedLicenseApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
{ label: 'Business Address', value: app.businessAddress },
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Shipping Company Agreement', ok: app.docsComplete },
{ label: 'Bank Letter (≥ 1.5M ETB)', ok: app.docsComplete },
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
{ label: 'Terminal Agreement / Title Deed', ok: app.docsComplete },
{ label: '2 Qualified Transit Employees (ERB Certificates)', ok: app.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function CombinedLicenseQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<CombinedLicenseApplication[]>(MOCK_COMBINED_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<CombinedLicenseApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/combined?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: LicenseStatus =
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved — pending payment.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/combined-license/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Combined Shipping Agent + Freight Forwarder License Queue</Title>
<Text fz="sm" c="dimmed">Review and process Combined Shipping Agent + Freight Forwarder License applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Bank Letter Amount</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/combined-license/${id}`)}
/>
</Stack>
);
}
export const COMBINED_LICENSE_ICON = IconShip;

View File

@@ -1,306 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCamera,
IconCertificate,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconShip,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_COMBINED_APPLICATIONS, STATUS_COLOR } from './CombinedLicenseQueuePage';
import type { CombinedLicenseApplication, LicenseStatus } from './CombinedLicenseQueuePage';
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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().split('T')[0];
}
const DOCS = [
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', fileName: 'shipping_agreement.pdf', icon: IconFileDescription, required: true },
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', fileName: 'bank_letter.pdf', icon: IconFileDescription, required: true },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', fileName: 'office_doc.pdf', icon: IconId, required: true },
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', fileName: 'terminal_doc.pdf', icon: IconId, required: true },
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile / CV / Work Experience', fileName: 'booking_clerk_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'canvasserDocs', label: 'Canvasser Profile / CV / Work Experience', fileName: 'canvasser_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'adminDocs', label: 'Administrative Staff Profile / CV / Work Experience', fileName: 'admin_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'ceoDocs', label: 'CEO / General Manager Profile / CV / Work Experience', fileName: 'ceo_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'transitErb', label: '2 Transit/Customs Employees — ERB Certificates', fileName: 'erb_certificates.pdf', icon: IconShieldCheck, required: true },
{ key: 'transitCv', label: '2 Transit/Customs Employees — CVs', fileName: 'transit_cvs.pdf', icon: IconFileDescription, required: true },
{ key: 'transitAgreements', label: '2 Transit/Customs Employees — Work Agreements', fileName: 'transit_agreements.pdf', icon: IconFileDescription, required: true },
{ key: 'commercialReg', label: 'Commercial Registration Certificate', fileName: 'commercial_reg.pdf', icon: IconId, required: true },
{ key: 'businessLicense', label: 'Business License', fileName: 'business_license.pdf', icon: IconId, required: true },
{ key: 'tinCert', label: 'TIN Certificate', fileName: 'tin_certificate.pdf', icon: IconId, required: true },
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
];
export function CombinedLicenseReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<CombinedLicenseApplication | null>(null);
const [status, setStatus] = useState<LicenseStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-licenses/combined/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_COMBINED_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as LicenseStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/combined-license')}>
Back to Queue
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
Back to Queue
</Button>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Combined Shipping Agent + Freight Forwarder License</Text>
</div>
</Group>
{status === 'Certificate Issued' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
<InfoRow label="Business Address" value={app.businessAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Bank Letter / Capital Evidence</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Bank Name" value={app.bankName} />
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
<InfoRow label="Minimum Required" value="1,500,000 ETB" />
<InfoRow label="Meets Threshold" value={app.capitalAmount >= 1500000 ? 'Yes' : 'No'} />
</SimpleGrid>
</Paper>
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{DOCS.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{status === 'Certificate Issued' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">Issued Certificate Combined Freight Forwarder and Shipping Agent License</Text>
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Combined Freight Forwarder and Shipping Agent License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,447 +1,146 @@
import { useState, type ElementType, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
Paper,
Badge,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Table,
Text,
Title,
Stack,
Group,
SimpleGrid,
Grid,
ThemeIcon,
Badge,
Avatar,
SegmentedControl,
Button,
Box,
UnstyledButton,
Anchor,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import {
IconUsers,
IconUserCheck,
IconUserPlus,
IconClockHour4,
IconShieldLock,
IconMail,
IconUpload,
IconDownload,
IconClipboardList,
IconArrowUpRight,
IconArrowDownRight,
IconArrowRight,
} from '@tabler/icons-react';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
CartesianGrid,
Tooltip,
Cell,
PieChart,
Pie,
} from 'recharts';
/* ------------------------------------------------------------------ */
/* sample data — swap for live API results */
/* ------------------------------------------------------------------ */
const REGISTRATIONS = [
{ month: 'Nov', value: 320 },
{ month: 'Dec', value: 410 },
{ month: 'Jan', value: 380 },
{ month: 'Feb', value: 520 },
{ month: 'Mar', value: 470 },
{ month: 'Apr', value: 610 },
{ month: 'May', value: 560 },
{ month: 'Jun', value: 720 },
];
const ROLES = [
{ name: 'Admin', value: 18, color: '#1d4ed8' },
{ name: 'Staff', value: 42, color: '#3b82f6' },
{ name: 'Viewer', value: 30, color: '#60a5fa' },
{ name: 'Guest', value: 10, color: '#93c5fd' },
];
function useKpis(t: (key: string) => string) {
return [
{ label: t('dashboard.kpis.totalUsers'), value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
{ label: t('dashboard.kpis.activeUsers'), value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
{ label: t('dashboard.kpis.newThisMonth'), value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
{ label: t('dashboard.kpis.pendingApprovals'), value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
];
}
function useQuickLinks(t: (key: string) => string) {
return [
{ label: t('dashboard.quickLinks.addUser'), desc: t('dashboard.quickLinks.addUserDesc'), icon: IconUserPlus, color: 'blue' },
{ label: t('dashboard.quickLinks.rolesAndPermissions'), desc: t('dashboard.quickLinks.rolesAndPermissionsDesc'), icon: IconShieldLock, color: 'indigo' },
{ label: t('dashboard.quickLinks.inviteMembers'), desc: t('dashboard.quickLinks.inviteMembersDesc'), icon: IconMail, color: 'cyan' },
{ label: t('dashboard.quickLinks.importUsers'), desc: t('dashboard.quickLinks.importUsersDesc'), icon: IconUpload, color: 'violet' },
{ label: t('dashboard.quickLinks.exportData'), desc: t('dashboard.quickLinks.exportDataDesc'), icon: IconDownload, color: 'blue' },
{ label: t('dashboard.quickLinks.auditLog'), desc: t('dashboard.quickLinks.auditLogDesc'), icon: IconClipboardList, color: 'grape' },
];
}
type UserStatus = 'Active' | 'Pending' | 'Invited';
const STATUS_COLOR: Record<UserStatus, string> = {
Active: 'teal',
Pending: 'orange',
Invited: 'blue',
};
const RECENT_USERS: {
name: string;
email: string;
initials: string;
color: string;
status: UserStatus;
}[] = [
{ name: 'Sara Tesfaye', email: 'sara.t@ema.gov.et', initials: 'ST', color: 'blue', status: 'Active' },
{ name: 'Daniel Bekele', email: 'daniel.b@ema.gov.et', initials: 'DB', color: 'indigo', status: 'Active' },
{ name: 'Hanna Girma', email: 'hanna.g@ema.gov.et', initials: 'HG', color: 'violet', status: 'Pending' },
{ name: 'Yonas Alemu', email: 'yonas.a@ema.gov.et', initials: 'YA', color: 'cyan', status: 'Active' },
{ name: 'Meron Tadesse', email: 'meron.t@ema.gov.et', initials: 'MT', color: 'grape', status: 'Invited' },
];
/* ------------------------------------------------------------------ */
/* small building blocks */
/* ------------------------------------------------------------------ */
function SectionCard({ children }: { children: ReactNode }) {
return (
<Paper p="lg" radius="lg" withBorder h="100%">
{children}
</Paper>
);
}
function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack gap={2}>
<Text fw={700} fz="lg">
{title}
</Text>
{subtitle && (
<Text size="sm" c="dimmed">
{subtitle}
</Text>
)}
</Stack>
);
}
function StatCard({
label,
value,
icon: Icon,
color,
trend,
up,
}: {
label: string;
value: string;
icon: ElementType;
color: string;
trend: string;
up: boolean;
}) {
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>
<Badge
variant="light"
color={up ? 'teal' : 'red'}
radius="sm"
leftSection={up ? <IconArrowUpRight size={12} /> : <IconArrowDownRight size={12} />}
>
{trend}
</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 QuickLinkTile({
label,
desc,
icon: Icon,
color,
}: {
label: string;
desc: string;
icon: ElementType;
color: string;
}) {
return (
<UnstyledButton
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-md)',
padding: 'var(--mantine-spacing-md)',
transition: 'border-color 120ms ease, box-shadow 120ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--mantine-color-blue-3)';
e.currentTarget.style.boxShadow = 'var(--mantine-shadow-sm)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--mantine-color-gray-2)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<ThemeIcon size={42} radius="md" variant="light" color={color}>
<Icon size={20} />
</ThemeIcon>
<Text fw={600} size="sm" mt="sm">
{label}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{desc}
</Text>
</UnstyledButton>
);
}
/* ------------------------------------------------------------------ */
/* page */
/* ------------------------------------------------------------------ */
STATUS_COLORS,
STATUS_LABELS,
useGetAssignedToMeQuery,
useGetQueueQuery,
type LicenseStatus,
} from '@ema-platform/api';
/**
* Backoffice home.
*
* Shows the licence pipeline, which is the part of the platform that has real
* data behind it. The previous version charted invented registration volumes
* and a fictional breakdown of staff roles.
*/
export function DashboardPage() {
const { t } = useTranslation();
const [range, setRange] = useState('week');
const kpis = useKpis(t);
const quickLinks = useQuickLinks(t);
const navigate = useNavigate();
const queue = useGetQueueQuery();
const mine = useGetAssignedToMeQuery();
if (queue.isLoading || mine.isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
const stats = [
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
{
label: 'Needs applicant action',
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
color: 'orange',
},
{
label: 'Awaiting payment',
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
color: 'yellow',
},
];
return (
<Stack gap="xl">
{/* header */}
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={4}>
<Title order={2}>{t('dashboard.title')}</Title>
<Text c="dimmed" size="sm">
{t('dashboard.subtitle')}
</Text>
</Stack>
<Group gap="sm">
<SegmentedControl
value={range}
onChange={setRange}
radius="md"
data={[
{ label: t('dashboard.timeRange.today'), value: 'today' },
{ label: t('dashboard.timeRange.thisWeek'), value: 'week' },
{ label: t('dashboard.timeRange.thisMonth'), value: 'month' },
]}
/>
<Button leftSection={<IconDownload size={16} />} radius="md">
{t('common.export')}
</Button>
</Group>
</Group>
<Container size="xl" py="md">
<Title order={3} mb="xs">
Dashboard
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the system.
</Text>
{/* KPIs */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{kpis.map((k) => (
<StatCard key={k.label} {...k} />
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (
<Card withBorder key={stat.label} padding="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
{stat.value}
</Text>
</Card>
))}
</SimpleGrid>
{/* charts */}
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="lg">
<CardHeading
title={t('dashboard.charts.userRegistrations')}
subtitle={t('dashboard.charts.registrationsSubtitle')}
/>
<Stack gap={0} align="flex-end">
<Text fw={700} c="teal">
+18.2%
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.vsPreviousPeriod')}
</Text>
</Stack>
</Group>
<Box h={260}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={REGISTRATIONS} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
<defs>
<linearGradient id="barBlue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" />
<stop offset="100%" stopColor="#2563eb" />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="#eef2f7" />
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#8d9bb3' }}
/>
<Tooltip
cursor={{ fill: '#f1f5fb' }}
contentStyle={{
borderRadius: 12,
border: '1px solid #e4e9f2',
fontSize: 12,
boxShadow: '0 4px 20px rgba(15,23,42,0.08)',
}}
/>
<Bar dataKey="value" radius={[6, 6, 0, 0]} maxBarSize={34}>
{REGISTRATIONS.map((entry, i) => (
<Cell
key={entry.month}
fill={i === REGISTRATIONS.length - 1 ? '#1d4ed8' : 'url(#barBlue)'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Box>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<CardHeading title={t('dashboard.charts.usersByRole')} subtitle={t('dashboard.charts.roleDistribution')} />
<Group mt="lg" justify="center" wrap="nowrap" gap="lg">
<Box pos="relative" w={168} h={168} style={{ flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={ROLES}
dataKey="value"
innerRadius={56}
outerRadius={82}
paddingAngle={2}
stroke="none"
startAngle={90}
endAngle={-270}
>
{ROLES.map((r) => (
<Cell key={r.name} fill={r.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: 12,
border: '1px solid #e4e9f2',
fontSize: 12,
}}
formatter={(value, name) => [`${value}%`, name]}
/>
</PieChart>
</ResponsiveContainer>
<Stack
gap={0}
align="center"
justify="center"
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
<Card withBorder padding={0} radius="md">
<Group justify="space-between" p="md" pb="xs">
<Text fw={600} size="sm">
Awaiting claim
</Text>
<Text
size="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
{unclaimed.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
Nothing waiting to be claimed.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{unclaimed.slice(0, 8).map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
<Text fw={800} fz={22}>
9,431
</Text>
<Text size="xs" c="dimmed">
{t('dashboard.charts.totalUsers')}
</Text>
</Stack>
</Box>
<Stack gap="md" style={{ flex: 1 }}>
{ROLES.map((r) => (
<Group key={r.name} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<Box w={10} h={10} style={{ borderRadius: 999, background: r.color }} />
<Text size="sm" c="dimmed">
{r.name}
</Text>
</Group>
<Text size="sm" fw={700}>
{r.value}%
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Group>
))}
</Stack>
</Group>
</SectionCard>
</Grid.Col>
</Grid>
{/* quick links + recent users */}
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<CardHeading title={t('dashboard.quickLinks.title')} subtitle={t('dashboard.quickLinks.subtitle')} />
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md" mt="lg">
{quickLinks.map((q) => (
<QuickLinkTile key={q.label} {...q} />
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={STATUS_COLORS[app.status as LicenseStatus]}
>
{STATUS_LABELS[app.status as LicenseStatus]}
</Badge>
</Table.Td>
</Table.Tr>
))}
</SimpleGrid>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">
{t('dashboard.recentUsers.title')}
</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>
</Group>
<Stack gap={0}>
{RECENT_USERS.map((u, i) => (
<Group
key={u.email}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom:
i < RECENT_USERS.length - 1
? '1px solid var(--mantine-color-gray-2)'
: 'none',
}}
>
<Group gap="sm" wrap="nowrap">
<Avatar color={u.color} radius="xl" size={38}>
{u.initials}
</Avatar>
<Stack gap={0}>
<Text size="sm" fw={600}>
{u.name}
</Text>
<Text size="xs" c="dimmed">
{u.email}
</Text>
</Stack>
</Group>
<Badge variant="light" color={STATUS_COLOR[u.status]} radius="sm">
{u.status}
</Badge>
</Group>
))}
</Stack>
</SectionCard>
</Grid.Col>
</Grid>
</Stack>
</Table.Tbody>
</Table>
)}
</Card>
</Container>
);
}
export default DashboardPage;

View File

@@ -1,318 +1,21 @@
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'];
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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: IconRubberStamp },
{ 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"><IconRubberStamp 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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Endorsement queue"
description="Endorsement processing is not connected to the backend yet."
/>
</Container>
);
}
export default EndorsementQueuePage;

View File

@@ -1,579 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// 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"><IconRubberStamp 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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={<IconRubberStamp 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={<IconRubberStamp 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"><IconRubberStamp 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}
<Container size="lg" py="xl">
<FeatureUnavailable
title="Endorsement review"
description="Endorsement processing is not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export default EndorsementReviewPage;

View File

@@ -1,418 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconTruck,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type LicenseStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Payment Pending'
| 'Payment Confirmed'
| 'Certificate Issued';
export interface FreightForwarderApplication {
id: string;
companyName: string;
tinNumber: string;
commercialRegNumber: string;
businessAddress: string;
bankName: string;
capitalAmount: number;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
docsComplete: boolean;
}
export const MOCK_FF_APPLICATIONS: FreightForwarderApplication[] = [
{
id: 'FF-2024-001',
companyName: 'Horizon Freight Solutions PLC',
tinNumber: 'TIN-0012345',
commercialRegNumber: 'CR-889001',
businessAddress: 'Addis Ababa, Bole Sub-city',
bankName: 'Commercial Bank of Ethiopia',
capitalAmount: 1800000,
status: 'Under Evaluation',
submittedDate: '2024-03-10',
approvalDate: null,
expiryDate: null,
remarks: 'Bank letter and employee documents under review.',
docsComplete: true,
},
{
id: 'FF-2024-002',
companyName: 'Nile Cargo Movers Ltd',
tinNumber: 'TIN-0056789',
commercialRegNumber: 'CR-771002',
businessAddress: 'Dire Dawa',
bankName: 'Awash Bank',
capitalAmount: 1450000,
status: 'Submitted',
submittedDate: '2024-04-01',
approvalDate: null,
expiryDate: null,
remarks: '',
docsComplete: false,
},
{
id: 'FF-2023-014',
companyName: 'Abyssinia Logistics PLC',
tinNumber: 'TIN-0034521',
commercialRegNumber: 'CR-660214',
businessAddress: 'Addis Ababa, Kirkos Sub-city',
bankName: 'Zemen Bank',
capitalAmount: 2100000,
status: 'Certificate Issued',
submittedDate: '2023-10-05',
approvalDate: '2023-11-02',
expiryDate: '2024-11-02',
remarks: 'All requirements verified. Certificate issued.',
docsComplete: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Certificate Issued': 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: FreightForwarderApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
{ label: 'Business Address', value: app.businessAddress },
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Bank Letter (≥ 1.5M ETB)', ok: app.docsComplete },
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
{ label: 'CEO CV & Work Agreement', ok: app.docsComplete },
{ label: '2 Qualified Transit Employees (ERB Certificates)', ok: app.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function FreightForwarderLicenseQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<FreightForwarderApplication[]>(MOCK_FF_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<FreightForwarderApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<FreightForwarderApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/freight-forwarder?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: LicenseStatus =
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved — pending payment.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/freight-forwarder-license/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Freight Forwarder License Queue</Title>
<Text fz="sm" c="dimmed">Review and process Freight Forwarder License applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Bank Letter Amount</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/freight-forwarder-license/${id}`)}
/>
</Stack>
);
}
export const FF_ICON = IconTruck;

View File

@@ -1,297 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCamera,
IconCertificate,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconTruck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_FF_APPLICATIONS, STATUS_COLOR } from './FreightForwarderLicenseQueuePage';
import type { FreightForwarderApplication, LicenseStatus } from './FreightForwarderLicenseQueuePage';
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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().split('T')[0];
}
const DOCS = [
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', fileName: 'bank_letter.pdf', icon: IconFileDescription, required: true },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', fileName: 'office_doc.pdf', icon: IconId, required: true },
{ key: 'ceoDocs', label: 'CEO CV & Work Agreement', fileName: 'ceo_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'adminDocs', label: 'Administrative Staff CV & Work Agreement', fileName: 'admin_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'transitErb', label: '2 Transit/Customs Employees — ERB Certificates', fileName: 'erb_certificates.pdf', icon: IconShieldCheck, required: true },
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
];
export function FreightForwarderLicenseReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<FreightForwarderApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<FreightForwarderApplication | null>(null);
const [status, setStatus] = useState<LicenseStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-licenses/freight-forwarder/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_FF_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as LicenseStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/freight-forwarder-license')}>
Back to Queue
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
Back to Queue
</Button>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconTruck size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Freight Forwarder License</Text>
</div>
</Group>
{status === 'Certificate Issued' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
<InfoRow label="Business Address" value={app.businessAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Bank Letter / Capital Evidence</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Bank Name" value={app.bankName} />
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
<InfoRow label="Minimum Required" value="1,500,000 ETB" />
<InfoRow label="Meets Threshold" value={app.capitalAmount >= 1500000 ? 'Yes' : 'No'} />
</SimpleGrid>
</Paper>
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{DOCS.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{status === 'Certificate Issued' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">Issued Certificate Freight Forwarder License</Text>
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Freight Forwarder License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,414 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconBuildingBank,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type LicenseStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Completed';
export interface JointInvestmentApplication {
id: string;
companyName: string;
tinNumber: string;
commercialRegNumber: string;
businessAddress: string;
bankName: string;
capitalAmount: number;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
docsComplete: boolean;
}
export const MOCK_JV_APPLICATIONS: JointInvestmentApplication[] = [
{
id: 'JV-2024-001',
companyName: 'Abyssinia-Sino Joint Venture PLC',
tinNumber: 'TIN-0098231',
commercialRegNumber: 'CR-990112',
businessAddress: 'Addis Ababa, Bole Sub-city',
bankName: 'Commercial Bank of Ethiopia',
capitalAmount: 1600000,
status: 'Under Evaluation',
submittedDate: '2024-03-12',
approvalDate: null,
expiryDate: null,
remarks: 'Shareholder documentation and capital contribution under review.',
docsComplete: true,
},
{
id: 'JV-2024-002',
companyName: 'Nile-Gulf Logistics Partners Ltd',
tinNumber: 'TIN-0071122',
commercialRegNumber: 'CR-881203',
businessAddress: 'Dire Dawa',
bankName: 'Awash Bank',
capitalAmount: 1200000,
status: 'Submitted',
submittedDate: '2024-04-05',
approvalDate: null,
expiryDate: null,
remarks: '',
docsComplete: false,
},
{
id: 'JV-2023-014',
companyName: 'Horn of Africa Investment Group PLC',
tinNumber: 'TIN-0045678',
commercialRegNumber: 'CR-661215',
businessAddress: 'Addis Ababa, Kirkos Sub-city',
bankName: 'Zemen Bank',
capitalAmount: 1900000,
status: 'Completed',
submittedDate: '2023-10-08',
approvalDate: '2023-11-10',
expiryDate: null,
remarks: 'All requirements verified. Decision and audit history recorded.',
docsComplete: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
Completed: 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: JointInvestmentApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Completed';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
{ label: 'Business Address', value: app.businessAddress },
{ label: 'Capital Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Bank Confirmation Letter / Capital Evidence', ok: app.docsComplete },
{ label: 'Company Establishment & JV/Ownership Documents', ok: app.docsComplete },
{ label: 'Vehicle Libre / Registration Copy', ok: app.docsComplete },
{ label: 'Renewed Business License & Commercial Registration', ok: app.docsComplete },
{ label: '3 Transit Professionals (Training Certificates)', ok: app.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function JointInvestmentLicenseQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<JointInvestmentApplication[]>(MOCK_JV_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<JointInvestmentApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<JointInvestmentApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/joint-investment?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: LicenseStatus =
action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
issued: apps.filter((a) => a.status === 'Completed').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/joint-investment-license/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Joint Investment / JV Business License Queue</Title>
<Text fz="sm" c="dimmed">Review and process Joint Investment / JV Business License applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Completed', value: stats.issued, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Completed']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Capital Amount</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/joint-investment-license/${id}`)}
/>
</Stack>
);
}
export const JV_ICON = IconBuildingBank;

View File

@@ -1,269 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCamera,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconBuildingBank,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_JV_APPLICATIONS, STATUS_COLOR } from './JointInvestmentLicenseQueuePage';
import type { JointInvestmentApplication, LicenseStatus } from './JointInvestmentLicenseQueuePage';
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>
);
}
const DOCS = [
{ key: 'applicationLetter', label: 'Application Letter / Online Form', fileName: 'application_letter.pdf', icon: IconFileDescription, required: true },
{ key: 'establishmentDoc', label: 'Company Establishment Document', fileName: 'establishment_doc.pdf', icon: IconId, required: true },
{ key: 'memorandum', label: 'Memorandum / Articles of Association / Bylaw', fileName: 'memorandum.pdf', icon: IconId, required: true },
{ key: 'orgProfile', label: 'Organizational Profile', fileName: 'org_profile.pdf', icon: IconFileDescription, required: true },
{ key: 'renewedBusinessLicense', label: 'Renewed Business License', fileName: 'renewed_business_license.pdf', icon: IconId, required: true },
{ key: 'commercialRegCert', label: 'Commercial Registration Certificate', fileName: 'commercial_reg_certificate.pdf', icon: IconId, required: true },
{ key: 'sectorEvidence', label: 'Evidence Company Is Active in Sector', fileName: 'sector_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'transitTrainingCert', label: 'Customs Transit Training Certificate', fileName: 'transit_training_certificate.pdf', icon: IconShieldCheck, required: true },
{ key: 'employmentContracts', label: 'Employment Contracts — 3 Transit Professionals', fileName: 'employment_contracts.pdf', icon: IconFileDescription, required: true },
{ key: 'educationEvidence', label: 'Education Evidence', fileName: 'education_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'workExperienceEvidence', label: 'Work Experience Evidence', fileName: 'work_experience_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'payrollEvidence', label: 'Three-Month Payroll Evidence', fileName: 'payroll_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'taxPaymentEvidence', label: 'Monthly Tax Payment Evidence', fileName: 'tax_payment_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'facilityEvidence', label: 'Vehicle / Machinery / Warehouse Evidence', fileName: 'facility_evidence.pdf', icon: IconId, required: true },
{ key: 'vehicleLibre', label: 'Vehicle Libre / Registration Copy', fileName: 'vehicle_libre.pdf', icon: IconId, required: true },
{ key: 'rentAgreement', label: 'Legal House / Office Rent Agreement', fileName: 'rent_agreement.pdf', icon: IconId, required: true },
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', fileName: 'bank_confirmation_letter.pdf', icon: IconFileDescription, required: true },
{ key: 'capitalBalanceEvidence', label: 'Capital Balance Evidence', fileName: 'capital_balance_evidence.pdf', icon: IconFileDescription, required: true },
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
];
export function JointInvestmentLicenseReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<JointInvestmentApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<JointInvestmentApplication | null>(null);
const [status, setStatus] = useState<LicenseStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-licenses/joint-investment/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_JV_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Completed';
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as LicenseStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/joint-investment-license')}>
Back to Queue
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
Back to Queue
</Button>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconBuildingBank size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Joint Investment / JV Business License</Text>
</div>
</Group>
{status === 'Completed' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Application Completed">
Decision and audit history recorded on {app.approvalDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
<InfoRow label="Business Address" value={app.businessAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Capital Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Bank Name" value={app.bankName} />
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{DOCS.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Completed']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Completed' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,183 @@
import { useMemo } from 'react';
import {
Badge,
Group,
Paper,
ScrollArea,
Text,
Timeline,
Tooltip,
} from '@mantine/core';
import {
IconFileUpload,
IconMessage,
IconArrowRight,
IconUserCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
type ApplicationDetail,
} from '@ema-platform/api';
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
interface ActivityEntry {
id: string;
kind: EntryKind;
at: string;
actor: string;
title: string;
detail?: string;
color?: string;
}
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
status: IconArrowRight,
remark: IconMessage,
upload: IconFileUpload,
assignment: IconUserCheck,
};
/**
* Chronological record of everything that has happened to an application.
*
* Merged client-side from the three collections the detail endpoint already
* returns — status transitions, officer remarks and document uploads. There is
* no single activity-feed endpoint, so this is assembled rather than fetched;
* the trade-off is that it can only show what the detail payload carries, and
* notifications sent to the applicant are not among them.
*/
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
const { t, i18n } = useTranslation();
const entries = useMemo<ActivityEntry[]>(() => {
const merged: ActivityEntry[] = [];
for (const history of detail.history ?? []) {
// A transition that does not move the status is a workflow control
// (assignment, escalation), not a decision — labelled as such so the
// trail does not read as "Under Review → Under Review".
const isAssignment = history.fromStatus === history.toStatus;
merged.push({
id: `status-${history.id}`,
kind: isAssignment ? 'assignment' : 'status',
at: history.createdAt,
actor: history.actorName ?? t('review.activity.system', 'System'),
title: isAssignment
? t(`review.events.${history.event}`, {
defaultValue: history.event,
})
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'}${
STATUS_LABELS[history.toStatus]
}`,
detail: history.remark ?? undefined,
color: STATUS_COLORS[history.toStatus],
});
}
for (const remark of detail.remarks ?? []) {
merged.push({
id: `remark-${remark.id}`,
kind: 'remark',
at: remark.createdAt,
actor: t('review.activity.officer', 'Officer'),
title: t('review.activity.remarkOn', {
target: remark.targetKey,
defaultValue: 'Correction requested on {{target}}',
}),
detail: remark.remark,
color: remark.resolvedAt ? 'teal' : 'orange',
});
}
for (const attachment of detail.attachments ?? []) {
const file = attachment.files?.[0];
if (!file) continue;
merged.push({
id: `upload-${attachment.id}`,
kind: 'upload',
at: attachment.createdAt ?? detail.application.createdAt,
actor: t('review.activity.applicant', 'Applicant'),
title: t('review.activity.uploaded', {
document: attachment.documentKey,
defaultValue: 'Uploaded {{document}}',
}),
detail: file.originalName,
color: 'blue',
});
}
// Newest first: an officer opening a review wants the latest state, not
// the application's origin story.
return merged.sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
}, [detail, t]);
if (entries.length === 0) {
return (
<Paper withBorder p="md">
<Text size="sm" c="dimmed">
{t('review.activity.empty', 'No activity recorded yet.')}
</Text>
</Paper>
);
}
return (
<Paper withBorder p="md" h="100%">
<Group justify="space-between" mb="sm">
<Text fw={600} size="sm">
{t('review.activity.title', 'Activity & audit trail')}
</Text>
<Badge variant="light" size="sm">
{entries.length}
</Badge>
</Group>
<ScrollArea.Autosize mah={520} type="hover" offsetScrollbars>
<Timeline bulletSize={20} lineWidth={2}>
{entries.map((entry) => {
const EntryIcon = ICONS[entry.kind];
return (
<Timeline.Item
key={entry.id}
bullet={<EntryIcon size={12} />}
color={entry.color}
title={
<Text size="xs" fw={600}>
{entry.title}
</Text>
}
>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{entry.actor}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Tooltip
label={new Date(entry.at).toLocaleString(i18n.language)}
withArrow
>
<Text size="xs" c="dimmed">
{new Date(entry.at).toLocaleDateString(i18n.language)}
</Text>
</Tooltip>
</Group>
{entry.detail && (
<Text size="xs" mt={2}>
{entry.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
</ScrollArea.Autosize>
</Paper>
);
}

View File

@@ -0,0 +1,243 @@
import {
ActionIcon,
Avatar,
Badge,
Button,
Divider,
Group,
Menu,
Paper,
Text,
Tooltip,
rem,
} from '@mantine/core';
import { IconDots, IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { STATUS_COLORS, STATUS_LABELS, type LicenseStatus } from '@ema-platform/api';
import type { ActionId, ResolvedAction } from '../config/actions';
import type { SlaState } from '../sla';
interface DecisionBarProps {
status: LicenseStatus;
assigneeName?: string | null;
sla?: SlaState;
actions: ResolvedAction[];
busyAction?: ActionId | null;
onAction: (action: ResolvedAction) => void;
}
/**
* The single place decisions are taken.
*
* Sticky to the bottom of the viewport and full workspace width, so it is
* reachable at any scroll depth — the actions used to sit in a right-hand
* column that scrolled away, meaning an officer reading the last document had
* to scroll back up to act on it.
*
* Layout follows the action tiers: workflow controls left, the decision right,
* everything else in the overflow menu. At most one filled button, so where to
* look is never ambiguous.
*/
export function DecisionBar({
status,
assigneeName,
sla,
actions,
busyAction,
onAction,
}: DecisionBarProps) {
const { t } = useTranslation();
const workflow = actions.filter((a) => a.tier === 'workflow');
// Three is the cap: past that the bar stops reading as a decision and starts
// reading as a toolbar. The rest stay reachable in the overflow menu.
const primary = actions.filter((a) => a.tier === 'primary').slice(0, 3);
const overflowPrimary = actions.filter((a) => a.tier === 'primary').slice(3);
const secondary = [
...overflowPrimary,
...actions.filter((a) => a.tier === 'secondary'),
];
const destructive = actions.filter((a) => a.tier === 'destructive');
return (
<Paper
withBorder
shadow="md"
px="lg"
py="sm"
style={{
position: 'sticky',
bottom: 0,
zIndex: 60,
borderRadius: 0,
marginInline: `calc(-1 * var(--mantine-spacing-lg))`,
background: 'var(--mantine-color-body)',
}}
role="region"
aria-label={t('review.decisionBar', 'Decision bar')}
>
<Group justify="space-between" wrap="nowrap" gap="md">
{/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{STATUS_LABELS[status]}
</Badge>
{assigneeName && (
<Group gap={6} wrap="nowrap">
<Avatar size={24} radius="xl" color="blue">
{assigneeName.slice(0, 2).toUpperCase()}
</Avatar>
<Text size="sm" c="dimmed" truncate>
{assigneeName}
</Text>
</Group>
)}
{sla && sla.state !== 'untracked' && (
<Tooltip label={sla.tooltip} withArrow>
<Badge
color={sla.color}
variant="light"
// Never colour alone: the label carries the same meaning for
// anyone who cannot distinguish the hues.
leftSection={
sla.state === 'breached' ? <IconAlertTriangle size={12} /> : undefined
}
>
{sla.label}
</Badge>
</Tooltip>
)}
{workflow.length > 0 && <Divider orientation="vertical" />}
{workflow.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="xs"
/>
))}
</Group>
{/* Right: the decision. */}
<Group gap="xs" wrap="nowrap">
{primary.map((action) => (
<ActionButton
key={action.id}
action={action}
busy={busyAction === action.id}
onAction={onAction}
size="sm"
/>
))}
{(secondary.length > 0 || destructive.length > 0) && (
<Menu position="top-end" withinPortal shadow="md" width={240}>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
aria-label={t('review.moreActions', 'More actions')}
>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{secondary.map((action) => (
<MenuAction key={action.id} action={action} onAction={onAction} />
))}
{destructive.length > 0 && (
<>
<Menu.Divider />
<Menu.Label>
{t('review.irreversible', 'Cannot be undone')}
</Menu.Label>
{destructive.map((action) => (
<MenuAction
key={action.id}
action={action}
onAction={onAction}
color="red"
/>
))}
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</Paper>
);
}
interface ActionButtonProps {
action: ResolvedAction;
busy: boolean;
size: 'xs' | 'sm';
onAction: (action: ResolvedAction) => void;
}
/**
* A disabled button always says why.
*
* Mantine strips pointer events from a disabled button, so the Tooltip has to
* wrap a span — otherwise the one case where the explanation matters is the
* one case it never appears.
*/
function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const { t } = useTranslation();
const button = (
<Button
size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
color={action.color}
loading={busy}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Button>
);
if (action.enabled) return button;
return (
<Tooltip label={action.disabledReason} withArrow position="top">
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
</Tooltip>
);
}
function MenuAction({
action,
onAction,
color,
}: {
action: ResolvedAction;
onAction: (action: ResolvedAction) => void;
color?: string;
}) {
const { t } = useTranslation();
const item = (
<Menu.Item
color={color}
disabled={!action.enabled}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
</Menu.Item>
);
if (action.enabled) return item;
return (
<Tooltip label={action.disabledReason} withArrow position="left">
<div>{item}</div>
</Tooltip>
);
}
export const DECISION_BAR_HEIGHT = rem(60);

View File

@@ -0,0 +1,311 @@
import { useEffect, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Group,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { ResolvedAction } from '../config/actions';
/** Reason codes offered per action. Free text is always available too. */
const REASON_CODES: Record<string, string[]> = {
reject: [
'review.reasons.incompleteDocuments',
'review.reasons.belowCapital',
'review.reasons.failedInspection',
'review.reasons.ineligibleApplicant',
'review.reasons.duplicateApplication',
],
'request-adjustment': [
'review.reasons.illegibleDocument',
'review.reasons.expiredDocument',
'review.reasons.missingDocument',
'review.reasons.inconsistentDetails',
],
hold: [
'review.reasons.awaitingThirdParty',
'review.reasons.legalProceedings',
'review.reasons.applicantRequest',
],
escalate: [
'review.reasons.aboveAuthority',
'review.reasons.policyUnclear',
'review.reasons.conflictOfInterest',
],
};
export interface DecisionSubmission {
reasonCode?: string;
reason: string;
/** Chosen officer, for Assign and Escalate. */
officerId?: string;
/** Documents the applicant must fix. Adjustments only. */
deficiencies: string[];
/** The message that will be sent, after any officer edit. */
notificationBody: string;
}
interface DecisionConfirmModalProps {
action: ResolvedAction | null;
applicantName: string;
applicationNumber: string;
/** Document keys the officer flagged, for the deficiency checklist. */
flaggedDocuments?: string[];
/** Populated for Assign and Escalate, which must name a person. */
officers?: Array<{ id: string; name: string | null }>;
submitting?: boolean;
onClose: () => void;
onConfirm: (submission: DecisionSubmission) => void;
}
/**
* One confirmation anatomy for every decision.
*
* Each decision used to have its own ad-hoc modal — some with a reason, some
* without, none showing what the applicant would actually receive. This gives
* all of them the same five parts: the consequence in plain language naming
* the applicant and application, a reason (required where it matters), the
* document deficiency checklist for adjustments, an editable preview of the
* message that will be sent, and an explicit warning where the step cannot be
* undone.
*/
export function DecisionConfirmModal({
action,
applicantName,
applicationNumber,
flaggedDocuments = [],
officers = [],
submitting,
onClose,
onConfirm,
}: DecisionConfirmModalProps) {
const { t } = useTranslation();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [reason, setReason] = useState('');
const [deficiencies, setDeficiencies] = useState<string[]>([]);
const [notification, setNotification] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [officerId, setOfficerId] = useState<string | null>(null);
const [confirmText, setConfirmText] = useState('');
const codes = action ? (REASON_CODES[action.id] ?? []) : [];
// `flaggedDocuments` is a fresh array on every parent render, so keying the
// reset effect on its identity would wipe the officer's edits continuously.
const flaggedKey = flaggedDocuments.join('|');
// Reset per opening, and seed the message the applicant will receive so the
// officer edits real copy rather than composing from nothing.
useEffect(() => {
if (!action) return;
setReasonCode(null);
setReason('');
setDeficiencies(flaggedDocuments);
setAcknowledged(false);
setOfficerId(null);
setConfirmText('');
setNotification(
t(`review.notifications.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.notifications.fallback', {
applicant: applicantName,
number: applicationNumber,
action: t(action.labelKey),
defaultValue:
'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
}),
}),
);
}, [action, applicantName, applicationNumber, flaggedKey, t]);
if (!action) return null;
const needsOfficer = action.id === 'assign' || action.id === 'escalate';
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
const blocked =
reasonMissing ||
(needsOfficer && !officerId) ||
(action.irreversible && !acknowledged) ||
// A destructive action must have the consequence typed out, not just
// acknowledged with a tick — it is the last stop before something
// irreversible happens to a real operator.
(action.tier === 'destructive' && confirmText.trim() !== applicationNumber);
return (
<Modal
opened
onClose={onClose}
title={t(action.labelKey)}
size="lg"
// Focus returns to the trigger on close, and Esc dismisses.
trapFocus
returnFocus
closeOnEscape
>
<Stack gap="md">
{/* 1. What is about to happen, in plain language. */}
<Text size="sm">
{t(`review.consequences.${action.id}`, {
applicant: applicantName,
number: applicationNumber,
defaultValue: t('review.consequences.fallback', {
applicant: applicantName,
number: applicationNumber,
defaultValue:
'This updates application {{number}} for {{applicant}}.',
}),
})}
</Text>
{/* 1b. Who picks it up. */}
{needsOfficer && (
<Select
label={
action.id === 'escalate'
? t('review.supervisor', 'Supervisor')
: t('review.officer', 'Officer')
}
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
data={officers.map((officer) => ({
value: officer.id,
label: officer.name ?? officer.id,
}))}
value={officerId}
onChange={setOfficerId}
searchable
withAsterisk
nothingFoundMessage={t('review.noOfficers', 'No officers found')}
/>
)}
{/* 2. Reason — coded plus free text. */}
{action.requiresReason && (
<>
{codes.length > 0 && (
<Select
label={t('review.reasonCode', 'Reason')}
placeholder={t('review.reasonCodePlaceholder', 'Select a reason')}
data={codes.map((code) => ({ value: code, label: t(code) }))}
value={reasonCode}
onChange={setReasonCode}
clearable
withAsterisk
/>
)}
<Textarea
label={t('review.reasonDetail', 'Details for the applicant')}
description={t(
'review.reasonDetailHint',
'This text is sent to the applicant verbatim.',
)}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
autosize
minRows={3}
withAsterisk={!reasonCode}
/>
</>
)}
{/* 3. Deficiency checklist — the applicant sees exactly this list. */}
{action.id === 'request-adjustment' && flaggedDocuments.length > 0 && (
<Checkbox.Group
label={t('review.deficiencies', 'Items the applicant must correct')}
description={t(
'review.deficienciesHint',
'Only the ticked items become editable for the applicant.',
)}
value={deficiencies}
onChange={setDeficiencies}
>
<Stack gap={4} mt="xs">
{flaggedDocuments.map((key) => (
<Checkbox key={key} value={key} label={key} />
))}
</Stack>
</Checkbox.Group>
)}
{/* 4. Editable preview of the outbound message. */}
<Textarea
label={t('review.notificationPreview', 'Message to the applicant')}
description={t(
'review.notificationPreviewHint',
'Sent by SMS and email. Edit before confirming if needed.',
)}
value={notification}
onChange={(event) => setNotification(event.currentTarget.value)}
autosize
minRows={3}
/>
{/* 5. Irreversibility, acknowledged explicitly. */}
{action.irreversible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
<Stack gap="xs">
<Text size="sm">
{t(
'review.irreversibleWarning',
'This decision is final and cannot be undone from the backoffice.',
)}
</Text>
<Checkbox
checked={acknowledged}
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
label={t('review.irreversibleAck', 'I understand this is final')}
/>
</Stack>
</Alert>
)}
{/* 5b. Destructive actions require the application number typed out. */}
{action.tier === 'destructive' && (
<TextInput
label={t('review.typeToConfirm', {
number: applicationNumber,
defaultValue: 'Type {{number}} to confirm',
})}
value={confirmText}
onChange={(event) => setConfirmText(event.currentTarget.value)}
placeholder={applicationNumber}
error={
confirmText && confirmText.trim() !== applicationNumber
? t('review.confirmMismatch', 'Does not match')
: undefined
}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
color={action.color}
loading={submitting}
disabled={blocked}
onClick={() =>
onConfirm({
reasonCode: reasonCode ?? undefined,
officerId: officerId ?? undefined,
reason: reason.trim() || (reasonCode ? t(reasonCode) : ''),
deficiencies,
notificationBody: notification,
})
}
>
{t(action.labelKey)}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,429 @@
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Checkbox,
Drawer,
Group,
Paper,
Progress,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconCheck,
IconDownload,
IconEye,
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
onFlagRemark: (documentKey: string, remark: string) => void;
}
/**
* The reviewer's document workspace.
*
* Previously a list with View and Download buttons that had no handlers at
* all — the officer could see that a document existed but not what was in it,
* which makes "approve documents" an act of faith. This previews inline,
* measures what is uploaded against what the licence type requires, and lets
* each document be flagged with its own reason.
*/
export function DocumentsTab({
applicationId,
attachments,
requirements,
flags,
onToggleFlag,
onFlagRemark,
}: DocumentsTabProps) {
const { t } = useTranslation();
const [preview, setPreview] = useState<Attachment | null>(null);
const [rejecting, setRejecting] = useState<Record<string, string>>({});
// Verdicts are persisted per document, so an accept survives a reload and
// is visible to whoever picks the application up next.
const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, {
skip: !applicationId,
});
const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation();
const [clearReview] = useClearDocumentReviewMutation();
const verdictFor = (documentKey: string) =>
reviews.find((review) => review.documentKey === documentKey);
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
});
return;
}
try {
await reviewDocument({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
const next = { ...prev };
delete next[documentKey];
return next;
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
return (
<Stack gap="md">
{/* Completeness against the licence type's own requirement list. */}
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => r.name.en ?? r.key).join(', ')}
</Text>
</Alert>
)}
</Paper>
{attachments.map((attachment) => {
const file = attachment.files?.[0];
const flagged = attachment.documentKey in flags;
const verdict = verdictFor(attachment.documentKey);
const pendingReject = attachment.documentKey in rejecting;
return (
<Paper withBorder p="md" key={attachment.id}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IconFileText size={20} stroke={1.6} />
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>
{attachment.documentKey}
</Text>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
</Badge>
)}
</Group>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<Button
size="compact-sm"
variant="light"
leftSection={<IconEye size={14} />}
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
}
>
<span>
<ActionIcon
variant="subtle"
disabled={!file?.url}
component="a"
href={file?.url}
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
>
<IconDownload size={16} />
</ActionIcon>
</span>
</Tooltip>
{/* Accept / Reject are the officer's own record of having
checked the file, persisted independently of any
adjustment round. */}
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
}
>
<IconCheck size={16} />
</ActionIcon>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
}))
}
>
<IconX size={16} />
</ActionIcon>
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
onClick={() =>
clearReview({
id: applicationId,
documentKey: attachment.documentKey,
})
}
>
<IconRotate size={16} />
</ActionIcon>
</Tooltip>
)}
<Checkbox
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
/>
</Group>
</Group>
{pendingReject && (
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
)}
value={rejecting[attachment.documentKey]}
onChange={(e) =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: e.currentTarget.value,
}))
}
/>
<Button
size="compact-sm"
color="red"
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
</Button>
</Group>
)}
{flagged && (
<TextInput
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
}
/>
)}
</Paper>
);
})}
<Drawer
opened={Boolean(preview)}
onClose={() => setPreview(null)}
position="right"
size="xl"
title={preview?.documentKey}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
trapFocus
returnFocus
>
{previewFile?.url ? (
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? 'document'}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? 'document'}
style={{ maxWidth: '100%' }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
)}
</Text>
<Button
component="a"
href={previewFile.url}
target="_blank"
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
</Button>
</Stack>
)
) : null}
</Drawer>
</Stack>
);
}

View File

@@ -0,0 +1,312 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { PERMISSIONS } from '../../../layouts/nav-config';
/**
* Where an action is rendered. One tier per action, decided here rather than
* by whoever happens to be laying out the page — that is what produced buttons
* scattered down the right-hand column with no ordering principle.
*/
export type ActionTier =
/** Approve / Request Adjustment / Reject. Decision Bar, right. Max three. */
| 'primary'
/** Claim, Assign, Escalate, Hold, Return. Decision Bar, left. */
| 'workflow'
/** Print, Export, Certificate, Audit, Copy Link. Overflow menu. */
| 'secondary'
/** Void, Revoke, Cancel. Overflow menu, separated, red, typed confirm. */
| 'destructive';
export type ActionId =
| 'claim'
| 'assign'
| 'escalate'
| 'hold'
| 'resume'
| 'complete-review'
| 'approve-documents'
| 'schedule-inspection'
| 'record-inspection'
| 'final-approve'
| 'request-adjustment'
| 'reject'
| 'confirm-payment'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| 'audit-trail';
export interface ActionDefinition {
id: ActionId;
tier: ActionTier;
labelKey: string;
/** Statuses the action can be fired from. Mirrors the API transition table. */
from?: LicenseStatus[];
/** Any one of these authorises it. Omitted means no permission needed. */
permissions?: string[];
/** Only one primary action is ever filled; everything else is light. */
emphasis?: 'filled' | 'light' | 'subtle';
color?: string;
/** Requires a typed reason before it will submit. */
requiresReason?: boolean;
/** Cannot be undone — the confirmation says so explicitly. */
irreversible?: boolean;
}
/**
* Every action an officer can take, in one place.
*
* Actions absent from this list are absent because the API has no endpoint for
* them. Void, Revoke and Cancel are the notable gaps: `SUSPEND_LICENSE` and
* `CANCEL_LICENSE` permissions exist, but no route does either, so rendering
* them would be a button that cannot work.
*/
export const ACTIONS: ActionDefinition[] = [
// ------------------------------------------------------------- workflow
{
id: 'claim',
tier: 'workflow',
labelKey: 'review.actions.claim',
from: ['SUBMITTED'],
permissions: ['can:claim:license-application'],
emphasis: 'light',
},
{
id: 'assign',
tier: 'workflow',
labelKey: 'review.actions.assign',
from: [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
],
permissions: ['can:assign:license-application'],
emphasis: 'subtle',
},
{
id: 'escalate',
tier: 'workflow',
labelKey: 'review.actions.escalate',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:escalate:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'hold',
tier: 'workflow',
labelKey: 'review.actions.hold',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:hold:license-application'],
emphasis: 'subtle',
requiresReason: true,
},
{
id: 'resume',
tier: 'workflow',
labelKey: 'review.actions.resume',
from: ['ON_HOLD'],
permissions: ['can:hold:license-application'],
emphasis: 'light',
},
// -------------------------------------------------------------- primary
{
id: 'complete-review',
tier: 'primary',
labelKey: 'review.actions.completeReview',
from: ['UNDER_REVIEW'],
permissions: [
'can:review:license-application',
'can:evaluate:license-application',
],
emphasis: 'filled',
},
{
id: 'approve-documents',
tier: 'primary',
labelKey: 'review.actions.approveDocuments',
from: ['UNDER_EVALUATION'],
permissions: ['can:evaluate:license-application'],
emphasis: 'filled',
},
{
id: 'schedule-inspection',
tier: 'primary',
labelKey: 'review.actions.scheduleInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:create:inspection'],
emphasis: 'filled',
},
{
id: 'record-inspection',
tier: 'primary',
labelKey: 'review.actions.recordInspection',
from: ['INSPECTION_PENDING'],
permissions: ['can:update:inspection'],
emphasis: 'filled',
},
{
id: 'final-approve',
tier: 'primary',
labelKey: 'review.actions.finalApprove',
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
permissions: ['can:approve:license-application'],
emphasis: 'filled',
color: 'teal',
irreversible: true,
},
{
id: 'request-adjustment',
tier: 'primary',
labelKey: 'review.actions.requestAdjustment',
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
permissions: ['can:request-adjustment:license-application'],
emphasis: 'light',
color: 'orange',
requiresReason: true,
},
{
id: 'reject',
tier: 'primary',
labelKey: 'review.actions.reject',
from: [
'UNDER_REVIEW',
'UNDER_EVALUATION',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
],
permissions: ['can:reject:license-application'],
emphasis: 'light',
color: 'red',
requiresReason: true,
irreversible: true,
},
{
id: 'confirm-payment',
tier: 'primary',
labelKey: 'review.actions.confirmPayment',
from: ['PAID'],
permissions: ['can:confirm:license-payment'],
emphasis: 'filled',
color: 'teal',
},
// ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
{ id: 'copy-link', tier: 'secondary', labelKey: 'review.actions.copyLink' },
{
id: 'download-documents',
tier: 'secondary',
labelKey: 'review.actions.downloadDocuments',
},
{
id: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
];
export interface ResolvedAction extends ActionDefinition {
/** False when the officer can see it but cannot fire it right now. */
enabled: boolean;
/**
* Why it is disabled, already translated. Never null when `enabled` is
* false — a greyed-out control with no explanation is the thing this whole
* model exists to prevent.
*/
disabledReason?: string;
}
export interface ResolveContext {
detail: ApplicationDetail;
currentUserId: string;
can: (permissions?: string[]) => boolean;
/** Translated strings for the disabled explanations. */
reasons: {
wrongStatus: string;
notAssigned: string;
noPermission: string;
needsFlags: string;
needsCapital: string;
needsInspection: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
}
/**
* Which actions to render, and for each, whether it can fire and why not.
*
* Actions the user has no permission for are dropped entirely; actions that
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
const disabled = (reason: string): ResolvedAction => ({
...action,
enabled: false,
disabledReason: reason,
});
// Decisions belong to whoever holds the application.
const needsOwnership =
action.tier === 'primary' && action.id !== 'confirm-payment';
if (
needsOwnership &&
app.assignedOfficerId &&
app.assignedOfficerId !== currentUserId
) {
return disabled(reasons.notAssigned);
}
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags);
}
if (action.id === 'final-approve') {
const threshold = app.licenseType?.capitalThreshold;
const needsCapital = threshold != null && Number(threshold) > 0;
if (needsCapital && app.capitalAmountVerified == null) {
return disabled(reasons.needsCapital);
}
if (
app.licenseType?.inspectionRequired &&
app.status !== 'INSPECTION_COMPLETED'
) {
return disabled(reasons.needsInspection);
}
}
return { ...action, enabled: true };
},
);
}

View File

@@ -0,0 +1,177 @@
import {
IconAnchor,
IconFileDescription,
IconShip,
IconTruck,
IconUsers,
type Icon,
} from '@tabler/icons-react';
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
/**
* Presentation-only metadata per licence type.
*
* Deliberately thin. Everything that actually varies between licence types —
* fees, capital threshold, validity, whether an inspection is required,
* whether a certificate is issued, the form schema, the document and staff
* requirements — already lives in the `license_types` table and arrives from
* `GET /license-types`. The API entity states the rule outright: onboarding a
* new type is meant to be a seed or admin change, not a code change
* (BR-MTO-020).
*
* Duplicating any of that here would fork the source of truth and mean a new
* licence type silently rendered with another type's rules. So this file holds
* only what the database has no opinion about: which icon to draw, and the
* order review tabs appear in.
*/
export interface LicenseTypePresentation {
/** Matches `LicenseType.key`. */
key: string;
icon: Icon;
/** Review tabs, in order. Tabs with no data do not render. */
detailSections: DetailSection[];
}
export type DetailSection =
| 'overview'
| 'company'
| 'financials'
| 'documents'
| 'staff'
| 'inspection';
const DEFAULT_SECTIONS: DetailSection[] = [
'overview',
'company',
'financials',
'documents',
'staff',
'inspection',
];
const PRESENTATION: Record<string, LicenseTypePresentation> = {
FREIGHT_FORWARDER: {
key: 'FREIGHT_FORWARDER',
icon: IconTruck,
detailSections: DEFAULT_SECTIONS,
},
SHIPPING_AGENT: {
key: 'SHIPPING_AGENT',
icon: IconShip,
detailSections: DEFAULT_SECTIONS,
},
COMBINED_SA_FF: {
key: 'COMBINED_SA_FF',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
},
JOINT_INVESTOR: {
key: 'JOINT_INVESTOR',
icon: IconUsers,
// Terminates at COMPLETED with no payment and no certificate, and the
// workflow skips inspection for it.
detailSections: ['overview', 'company', 'financials', 'documents', 'staff'],
},
MULTIMODAL_TRANSPORT_OPERATOR: {
key: 'MULTIMODAL_TRANSPORT_OPERATOR',
icon: IconAnchor,
detailSections: DEFAULT_SECTIONS,
},
};
/** Falls back to a generic presentation so an unseeded type still renders. */
export function presentationFor(key: string | undefined): LicenseTypePresentation {
return (
(key && PRESENTATION[key]) || {
key: key ?? 'UNKNOWN',
icon: IconFileDescription,
detailSections: DEFAULT_SECTIONS,
}
);
}
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
// ---------------------------------------------------------------- eligibility
export interface EligibilityRule {
id: string;
/** Plain-language statement of the rule, already interpolated. */
label: string;
/** What the application actually declares/verifies, formatted. */
actual: string;
status: 'pass' | 'fail' | 'unknown';
}
/**
* Turns the licence type's configured thresholds into a checked list.
*
* The capital threshold used to be applied invisibly — the officer saw a
* disabled approve button and had to know why. Rendering it as an explicit
* pass/fail line means the rule, the figure it was checked against, and the
* outcome are all on screen.
*/
export function evaluateEligibility(
application: LicenseApplication,
licenseType: LicenseType | undefined,
locale: string,
): EligibilityRule[] {
const rules: EligibilityRule[] = [];
const threshold =
licenseType?.capitalThreshold == null
? undefined
: Number(licenseType.capitalThreshold);
if (threshold !== undefined && !Number.isNaN(threshold)) {
const verified =
application.capitalAmountVerified == null
? undefined
: Number(application.capitalAmountVerified);
const declared =
application.capitalAmountDeclared == null
? undefined
: Number(application.capitalAmountDeclared);
const effective = verified ?? declared;
const currency = licenseType?.feeCurrency ?? 'ETB';
const format = (value: number) =>
`${value.toLocaleString(locale)} ${currency}`;
rules.push({
id: 'capital-threshold',
label: `Paid-up capital ≥ ${format(threshold)}`,
actual:
effective === undefined
? 'Not recorded'
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
// An unverified declaration is not evidence, so it reads as unknown
// rather than as a pass the officer never actually made.
status:
effective === undefined || verified === undefined
? 'unknown'
: effective >= threshold
? 'pass'
: 'fail',
});
}
if (licenseType?.inspectionRequired) {
const inspected = [
'INSPECTION_COMPLETED',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
].includes(application.status);
rules.push({
id: 'inspection',
label: 'Physical inspection completed',
actual: inspected ? 'Recorded' : 'Not yet recorded',
status: inspected ? 'pass' : 'unknown',
});
}
return rules;
}

View File

@@ -0,0 +1,72 @@
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
import { computeSla } from './sla';
/**
* Escapes one CSV field.
*
* Company names routinely contain commas, and remarks contain quotes and
* newlines — unescaped, either one shifts every later column on the row.
*/
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
const COLUMNS: Array<{
header: string;
value: (app: LicenseApplication, locale: string) => unknown;
}> = [
{ header: 'Application #', value: (a) => a.applicationNumber },
{ header: 'Company', value: (a) => a.companyName },
{ header: 'Trade name', value: (a) => a.tradeName },
{ header: 'TIN', value: (a) => a.tinNumber },
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
{ header: 'Kind', value: (a) => a.kind },
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
{
header: 'Submitted',
value: (a, locale) =>
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
},
{
header: 'Decided',
value: (a, locale) =>
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
},
{ header: 'SLA', value: (a) => computeSla(a).label },
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
];
/**
* Exports exactly the rows passed in.
*
* Takes the already-filtered, already-paged list rather than re-querying, so
* what lands in the file is what the officer was looking at. Note this means
* an export covers the current page — exporting a whole filtered result set
* would need a server-side export endpoint, which does not exist.
*/
export function exportApplicationsCsv(
applications: LicenseApplication[],
locale: string,
filename = `licence-applications-${new Date().toISOString().slice(0, 10)}.csv`,
): void {
const header = COLUMNS.map((column) => csvCell(column.header)).join(',');
const rows = applications.map((app) =>
COLUMNS.map((column) => csvCell(column.value(app, locale))).join(','),
);
// BOM so Excel opens Amharic and other non-ASCII content as UTF-8.
const blob = new Blob(['', [header, ...rows].join('\r\n')], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,722 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
ActionIcon,
Badge,
Button,
Card,
Checkbox,
Container,
Group,
MultiSelect,
Pagination,
Paper,
SegmentedControl,
Select,
Skeleton,
Stack,
Kbd,
Modal,
Table,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import {
IconAlertCircle,
IconDownload,
IconRefresh,
IconSearch,
IconSortAscending,
IconSortDescending,
IconX,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useClaimApplicationMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
useGetLicenseTypesQuery,
useGetQueueCountsQuery,
useGetQueueQuery,
useLazyExportApplicationsQuery,
type LicenseApplication,
type LicenseStatus,
type QueueFilter,
} from '@ema-platform/api';
import { EmptyState, ErrorState } from '@ema-platform/ui';
import { computeSla } from '../sla';
import {
DEFAULT_VIEW,
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
searchParamsFromFilter,
writeLastView,
type SavedViewId,
} from '../queue-views';
import { exportApplicationsCsv } from '../export';
import { setDensity } from '../../../store/preferences.slice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
const PAGE_SIZE = 25;
const SEARCH_DEBOUNCE_MS = 300;
const ALL_STATUSES: LicenseStatus[] = [
'SUBMITTED',
'UNDER_REVIEW',
'UNDER_EVALUATION',
'RESUBMIT_REQUIRED',
'INSPECTION_PENDING',
'INSPECTION_COMPLETED',
'ON_HOLD',
'APPROVED',
'PAYMENT_PENDING',
'PAID',
'PAYMENT_CONFIRMED',
'CERTIFICATE_ISSUED',
'COMPLETED',
'REJECTED',
];
/**
* The officer work pool.
*
* Saved views across the top, facets serialised into the URL so a filtered
* queue can be shared, and server-side pagination — the previous version
* rendered `data.items` unpaged, which was fine at demo volumes and would have
* stopped being fine somewhere in the hundreds.
*/
export function LicenseQueuePage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { typeCode } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const dispatch = useAppDispatch();
const density = useAppSelector((state) => state.preferences.density);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get('view') as SavedViewId) || readLastView(),
);
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
const [selected, setSelected] = useState<string[]>([]);
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
const [cursor, setCursor] = useState(0);
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],
);
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: counts } = useGetQueueCountsQuery();
// A `/licence-review/type/:typeCode` deep link pins the type facet.
const pinnedTypeId = useMemo(() => {
if (!typeCode) return undefined;
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
}, [typeCode, licenseTypes]);
const filter: QueueFilter = useMemo(
() => ({
...activeView.filter,
...urlFilter,
search: debouncedSearch || undefined,
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
}),
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
);
// One query per source; the two inactive ones are skipped, so switching
// views costs a single request rather than keeping three in flight.
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
const active =
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
/**
* Exports every row the filter matches, not just the page on screen.
* The server caps the result set and reports when it did, so a truncated
* export says so instead of quietly being wrong.
*/
async function handleExport() {
try {
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
exportApplicationsCsv(result.items, i18n.language);
if (result.truncated) {
notifications.show({
color: 'yellow',
title: t('queue.exportTruncated', 'Export truncated'),
message: t('queue.exportTruncatedBody', {
exported: result.items.length,
total: result.total,
defaultValue:
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
}),
});
}
} catch (err) {
notifications.show({
color: 'red',
title: t('queue.exportFailed', 'Export failed'),
message: extractErrorMessage(err),
});
}
}
const items = active.data?.items ?? [];
const total = active.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const updateUrl = useCallback(
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
setSearchParams(
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
{ replace: true },
);
},
[urlFilter, setSearchParams],
);
const changeView = (next: SavedViewId) => {
setView(next);
writeLastView(next);
setPage(1);
setSelected([]);
updateUrl({}, next, 1);
};
const setFacet = (next: Partial<QueueFilter>) => {
setPage(1);
updateUrl(next, view, 1);
};
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
setFacet({ sortBy: field, sortDir: dir });
};
async function handleClaim(id: string) {
try {
await claim(id).unwrap();
notifications.show({
color: 'teal',
title: t('queue.claimed', 'Claimed'),
message: t('queue.claimedBody', 'The application is now assigned to you.'),
});
changeView('mine');
} catch (err) {
// A 409 means another officer got there first — refresh so the queue
// stops showing work that is no longer available.
notifications.show({
color: 'red',
title: t('queue.claimFailed', 'Could not claim'),
message: extractErrorMessage(
err,
t('queue.claimRace', 'Another officer already claimed it.'),
),
});
active.refetch();
}
}
async function handleBulkClaim() {
const results = await Promise.allSettled(
selected.map((id) => claim(id).unwrap()),
);
const claimed = results.filter((r) => r.status === 'fulfilled').length;
const lost = results.length - claimed;
notifications.show({
color: lost ? 'yellow' : 'teal',
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
// Partial success is the normal case in a shared queue, so it is
// reported rather than swallowed or treated as total failure.
message: lost
? t('queue.bulkClaimPartial', {
count: lost,
defaultValue: '{{count}} were already taken by another officer.',
})
: '',
});
setSelected([]);
active.refetch();
}
const cursorRow = items[cursor];
useQueueKeyboard({
enabled: !helpOpen,
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
// rather than an error the officer has to read.
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true),
});
const allSelected = items.length > 0 && selected.length === items.length;
const sortIcon =
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
const hasFacets = Boolean(
urlFilter.status?.length ||
urlFilter.licenseTypeId ||
urlFilter.assignee ||
urlFilter.submittedFrom ||
debouncedSearch,
);
return (
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<Group gap="xs">
<Tooltip label={t('queue.refresh', 'Refresh')}>
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<SegmentedControl
size="xs"
value={density}
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
data={[
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
{ label: t('queue.compact', 'Compact'), value: 'compact' },
]}
/>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={handleExport}
loading={exporting}
disabled={total === 0}
>
{t('queue.export', 'Export CSV')}
</Button>
</Group>
</Group>
{/* Saved views, counted. */}
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
<Tabs.List>
{SAVED_VIEWS.map((savedView) => (
<Tabs.Tab
key={savedView.id}
value={savedView.id}
rightSection={
counts?.[savedView.countKey] ? (
<Badge size="xs" variant="light" circle>
{counts[savedView.countKey]}
</Badge>
) : undefined
}
>
{t(savedView.labelKey)}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
{/* Facets — every one of these is reflected in the URL. */}
<Paper withBorder p="sm" mb="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('queue.search', 'Search')}
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => setSearchInput(e.currentTarget.value)}
w={240}
/>
<MultiSelect
label={t('queue.status', 'Status')}
placeholder={t('queue.anyStatus', 'Any')}
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={urlFilter.status ?? []}
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
clearable
w={240}
/>
{!typeCode && (
<Select
label={t('queue.type', 'Licence type')}
placeholder={t('queue.anyType', 'Any')}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: type.name.en ?? type.key,
}))}
value={urlFilter.licenseTypeId ?? null}
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
clearable
w={220}
/>
)}
<TextInput
type="date"
label={t('queue.submittedFrom', 'Submitted from')}
value={urlFilter.submittedFrom ?? ''}
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
/>
<TextInput
type="date"
label={t('queue.submittedTo', 'Submitted to')}
value={urlFilter.submittedTo ?? ''}
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
/>
{hasFacets && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={() => {
setSearchInput('');
setSearchParams(new URLSearchParams(), { replace: true });
}}
>
{t('queue.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<Card withBorder padding={0}>
{active.isLoading ? (
// Skeleton rows match the real table, so the layout does not jump
// when data lands.
<Stack gap={0} p="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={44} mb="xs" radius="sm" />
))}
</Stack>
) : active.isError ? (
<ErrorState
title={t('queue.errorTitle', 'Could not load the queue')}
description={extractErrorMessage(active.error)}
onRetry={() => active.refetch()}
icon={IconAlertCircle}
/>
) : items.length === 0 ? (
<EmptyState
title={
hasFacets
? t('queue.emptyFiltered', 'No applications match these filters')
: t('queue.empty', 'Nothing waiting here')
}
description={
hasFacets
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
}
action={
hasFacets
? {
label: t('queue.clearFilters', 'Clear'),
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
}
: undefined
}
/>
) : (
<>
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label={t('queue.selectAll', 'Select all')}
checked={allSelected}
indeterminate={selected.length > 0 && !allSelected}
onChange={() =>
setSelected(allSelected ? [] : items.map((a) => a.id))
}
/>
</Table.Th>
<SortableTh
label={t('queue.number', 'App #')}
field="applicationNumber"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.company', 'Company')}
field="companyName"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
<SortableTh
label={t('queue.statusCol', 'Status')}
field="status"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<SortableTh
label={t('queue.submitted', 'Submitted')}
field="submittedAt"
current={urlFilter.sortBy}
icon={sortIcon}
onSort={toggleSort}
/>
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app, index) => (
<QueueRow
key={app.id}
app={app}
focused={index === cursor}
selected={selected.includes(app.id)}
claiming={claiming}
locale={i18n.language}
onSelect={(checked) =>
setSelected((prev) =>
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
)
}
onClaim={() => handleClaim(app.id)}
onOpen={() => navigate(`/licence-review/${app.id}`)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="space-between" p="sm">
<Text size="sm" c="dimmed">
{t('queue.showing', {
from: (page - 1) * PAGE_SIZE + 1,
to: Math.min(page * PAGE_SIZE, total),
total,
defaultValue: 'Showing {{from}}{{to}} of {{total}}',
})}
</Text>
<Pagination
value={page}
onChange={(next) => {
setPage(next);
updateUrl({}, view, next);
}}
total={pageCount}
size="sm"
/>
</Group>
</>
)}
</Card>
<Modal
opened={helpOpen}
onClose={() => setHelpOpen(false)}
title={t('shortcuts.title', 'Keyboard shortcuts')}
size="sm"
>
<Stack gap="xs">
{KEYBOARD_SHORTCUTS.map((shortcut) => (
<Group key={shortcut.keys} justify="space-between">
<Text size="sm">{t(shortcut.labelKey)}</Text>
<Kbd>{shortcut.keys}</Kbd>
</Group>
))}
</Stack>
</Modal>
{/* Bulk bar. Floating, with the count stated so the scope of the action
is never ambiguous. */}
{selected.length > 0 && (
<Paper
withBorder
shadow="md"
p="sm"
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('queue.selectedCount', {
count: selected.length,
defaultValue: '{{count}} selected',
})}
</Text>
<Group gap="xs">
<Button variant="subtle" onClick={() => setSelected([])}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() =>
exportApplicationsCsv(
items.filter((a) => selected.includes(a.id)),
i18n.language,
)
}
>
{t('queue.export', 'Export CSV')}
</Button>
<Button loading={claiming} onClick={handleBulkClaim}>
{t('queue.bulkClaim', {
count: selected.length,
defaultValue: 'Claim {{count}}',
})}
</Button>
</Group>
</Group>
</Paper>
)}
</Container>
);
}
function SortableTh({
label,
field,
current,
icon,
onSort,
}: {
label: string;
field: NonNullable<QueueFilter['sortBy']>;
current?: QueueFilter['sortBy'];
icon: React.ReactNode;
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
}) {
return (
<Table.Th>
<Group
gap={4}
wrap="nowrap"
style={{ cursor: 'pointer' }}
onClick={() => onSort(field)}
>
<span>{label}</span>
{current === field && icon}
</Group>
</Table.Th>
);
}
function QueueRow({
app,
selected,
focused,
claiming,
locale,
onSelect,
onClaim,
onOpen,
}: {
app: LicenseApplication;
selected: boolean;
focused: boolean;
claiming: boolean;
locale: string;
onSelect: (checked: boolean) => void;
onClaim: () => void;
onOpen: () => void;
}) {
const { t } = useTranslation();
const sla = computeSla(app);
return (
<Table.Tr
// Keyboard cursor. Marked with a left border rather than a background so
// it stays distinguishable from row selection and from hover.
style={
focused
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
: undefined
}
>
<Table.Td>
<Checkbox
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
checked={selected}
onChange={(e) => onSelect(e.currentTarget.checked)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
</Text>
</Table.Td>
<Table.Td>
{/* Colour is never the only signal — the label says the same thing. */}
<Tooltip label={sla.tooltip} withArrow>
<Badge color={sla.color} variant="light" size="sm">
{sla.label}
</Badge>
</Tooltip>
</Table.Td>
<Table.Td align="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
<Button size="xs" loading={claiming} onClick={onClaim}>
{t('queue.claim', 'Claim')}
</Button>
) : (
<Button size="xs" variant="light" onClick={onOpen}>
{t('queue.review', 'Review')}
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
export default LicenseQueuePage;

View File

@@ -0,0 +1,892 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Card,
Checkbox,
Container,
Grid,
Group,
Modal,
NumberInput,
Paper,
Skeleton,
Stack,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Timeline,
Title,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCheck,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconQuestionMark,
IconX,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useApproveDocumentsMutation,
useAssignApplicationMutation,
useCompleteReviewMutation,
useConfirmPaymentMutation,
useEscalateApplicationMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
useGetInspectionsQuery,
useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery,
useHoldApplicationMutation,
useRecordInspectionResultMutation,
useRejectApplicationMutation,
useRequestAdjustmentMutation,
useResumeApplicationMutation,
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
import { ErrorState } from '@ema-platform/ui';
import { usePermissions } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { DecisionBar } from '../components/DecisionBar';
import {
DecisionConfirmModal,
type DecisionSubmission,
} from '../components/DecisionConfirmModal';
import { ActivityRail } from '../components/ActivityRail';
import { DocumentsTab } from '../components/DocumentsTab';
import { computeSla } from '../sla';
import { evaluateEligibility, presentationFor } from '../config/license-types';
import { resolveActions, type ActionId, type ResolvedAction } from '../config/actions';
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
/**
* The officer's review workspace.
*
* Three zones: a sticky left rail carrying the summary and eligibility, a
* centre column of tabs driven by the licence type's sections, and a
* collapsible activity trail on the right. Every decision goes through the
* Decision Bar pinned to the bottom, so an officer can act from any scroll
* position instead of scrolling back to a column of buttons.
*/
export function LicenseReviewPage() {
const { t, i18n } = useTranslation();
const { id = '' } = useParams();
const { can } = usePermissions();
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
const { data, isLoading, isError, error, refetch } = useGetApplicationForReviewQuery(id, {
skip: !id,
});
const { data: inspections = [], refetch: refetchInspections } = useGetInspectionsQuery(id, {
skip: !id,
});
const { data: requirements } = useGetLicenseTypeRequirementsQuery(
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
{ skip: !data?.application.licenseTypeId },
);
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
const [approveDocuments] = useApproveDocumentsMutation();
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [confirmPayment] = useConfirmPaymentMutation();
const [holdApplication] = useHoldApplicationMutation();
const [resumeApplication] = useResumeApplicationMutation();
const [escalateApplication] = useEscalateApplicationMutation();
const [assignApplication] = useAssignApplicationMutation();
// Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery();
const [flags, setFlags] = useState<FlagMap>({});
const [capital, setCapital] = useState<number | undefined>();
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null);
const [busyAction, setBusyAction] = useState<ActionId | null>(null);
const [railOpen, setRailOpen] = useState(true);
const [inspectionOpen, setInspectionOpen] = useState(false);
const [inspectionDate, setInspectionDate] = useState('');
const [resultOpen, setResultOpen] = useState(false);
const [findings, setFindings] = useState('');
// Prefill from whatever is recorded, else the declared figure, so the officer
// confirms a number rather than retyping it. Runs before the early return
// below because hooks must be called unconditionally.
const seeded = useRef(false);
const loadedApp = data?.application;
useEffect(() => {
if (seeded.current || !loadedApp) return;
const existing = loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared;
if (existing != null) setCapital(Number(existing));
seeded.current = true;
}, [loadedApp]);
const toggleFlag = useCallback((targetType: RemarkTargetType, key: string) => {
setFlags((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { targetType, remark: '' };
return next;
});
}, []);
const documentFlags = useMemo(() => {
const map: Record<string, string> = {};
for (const [key, flag] of Object.entries(flags)) {
if (flag.targetType === 'DOCUMENT') map[key] = flag.remark;
}
return map;
}, [flags]);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
const flagged = Object.entries(flags);
const actions = useMemo(() => {
if (!data) return [];
return resolveActions({
detail: data,
currentUserId,
can,
flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection),
reasons: {
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
noPermission: t('review.disabled.noPermission', 'You do not have permission'),
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
},
});
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
if (isLoading) {
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
return (
<Container size="xl" py="md">
<Skeleton height={36} width={320} mb="lg" />
<Grid>
<Grid.Col span={{ base: 12, md: 3 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Skeleton height={420} radius="md" />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 3 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
</Grid>
</Container>
);
}
if (isError || !data) {
return (
<Container size="xl" py="md">
<ErrorState
title={t('review.errorTitle', 'Could not load this application')}
description={extractErrorMessage(error)}
onRetry={() => refetch()}
/>
</Container>
);
}
const app = data.application;
const status = app.status;
const presentation = presentationFor(app.licenseType?.key);
const sla = computeSla(app);
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
const rawThreshold = app.licenseType?.capitalThreshold;
const threshold =
rawThreshold === null || rawThreshold === undefined ? undefined : Number(rawThreshold);
/** Stages where the officer can still record the verified capital. */
const needsCapital =
Boolean(threshold) &&
['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_PENDING', 'INSPECTION_COMPLETED'].includes(
status,
);
async function run(action: () => Promise<unknown>, success: string) {
try {
await action();
notifications.show({ color: 'teal', title: success, message: '' });
refetch();
refetchInspections();
} catch (err) {
notifications.show({
color: 'red',
title: t('review.actionFailed', 'Action failed'),
message: extractErrorMessage(err),
});
}
}
/** Actions with their own dedicated form open that; the rest confirm. */
function handleAction(action: ResolvedAction) {
switch (action.id) {
case 'schedule-inspection':
setInspectionOpen(true);
return;
case 'record-inspection':
setResultOpen(true);
return;
case 'copy-link':
navigator.clipboard.writeText(window.location.href);
notifications.show({
color: 'teal',
title: t('review.linkCopied', 'Link copied'),
message: '',
});
return;
case 'print':
window.print();
return;
case 'audit-trail':
setRailOpen(true);
return;
case 'download-documents':
for (const attachment of data?.attachments ?? []) {
const url = attachment.files?.[0]?.url;
if (url) window.open(url, '_blank', 'noopener');
}
return;
default:
setPendingAction(action);
}
}
async function submitDecision(submission: DecisionSubmission) {
const action = pendingAction;
if (!action) return;
setBusyAction(action.id);
try {
switch (action.id) {
case 'claim':
// Claim is fired from the queue in practice; kept here for the case
// where an officer opens an unclaimed application directly.
break;
case 'complete-review':
await run(
() => completeReview({ id, capitalAmountVerified: capital }).unwrap(),
t('review.done.completeReview', 'Review completed'),
);
break;
case 'approve-documents':
await run(
() => approveDocuments({ id }).unwrap(),
t('review.done.approveDocuments', 'Documents approved'),
);
break;
case 'final-approve':
await run(
() => finalApprove({ id, capitalAmountVerified: capital }).unwrap(),
t('review.done.finalApprove', 'Approved'),
);
break;
case 'request-adjustment':
await run(async () => {
await requestAdjustment({
id,
generalRemark: submission.reason,
notificationBody: submission.notificationBody,
items: flagged
// Only the ticked deficiencies are sent, so the applicant can
// edit exactly the list they were shown.
.filter(([key]) =>
submission.deficiencies.length
? submission.deficiencies.includes(key)
: true,
)
.map(([key, flag]) => ({
targetType: flag.targetType,
targetKey: key,
remark: flag.remark,
})),
}).unwrap();
setFlags({});
}, t('review.done.requestAdjustment', 'Adjustment requested'));
break;
case 'reject':
await run(
() =>
rejectApplication({
id,
reason: submission.reason,
// The preview the officer edited is what actually gets sent.
notificationBody: submission.notificationBody,
}).unwrap(),
t('review.done.reject', 'Application rejected'),
);
break;
case 'confirm-payment':
await run(
() => confirmPayment(id).unwrap(),
t('review.done.confirmPayment', 'Payment confirmed'),
);
break;
case 'hold':
await run(
() => holdApplication({ id, reason: submission.reason }).unwrap(),
t('review.done.hold', 'Application placed on hold'),
);
break;
case 'resume':
await run(
() => resumeApplication({ id, remark: submission.reason }).unwrap(),
t('review.done.resume', 'Application resumed'),
);
break;
case 'escalate':
if (!submission.officerId) return;
await run(
() =>
escalateApplication({
id,
supervisorId: submission.officerId as string,
reason: submission.reason,
}).unwrap(),
t('review.done.escalate', 'Escalated'),
);
break;
case 'assign':
if (!submission.officerId) return;
await run(
() =>
assignApplication({
id,
officerId: submission.officerId as string,
remark: submission.reason,
}).unwrap(),
t('review.done.assign', 'Reassigned'),
);
break;
default:
break;
}
} finally {
setBusyAction(null);
setPendingAction(null);
}
}
const sections = presentation.detailSections;
const formSections = Object.entries(app.formData ?? {});
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
<Group gap="xs">
<Text size="sm" c="dimmed">
{app.applicationNumber}
</Text>
<Badge color={STATUS_COLORS[status]} variant="light">
{STATUS_LABELS[status]}
</Badge>
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
{t('review.round', { count: app.adjustmentRound, defaultValue: 'round {{count}}' })}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
<Tooltip
label={
railOpen
? t('review.hideActivity', 'Hide activity')
: t('review.showActivity', 'Show activity')
}
>
<ActionIcon variant="default" size="lg" onClick={() => setRailOpen((o) => !o)}>
{railOpen ? (
<IconLayoutSidebarRightCollapse size={18} />
) : (
<IconLayoutSidebarRightExpand size={18} />
)}
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Grid>
{/* Zone 1 — sticky summary rail. */}
<Grid.Col span={{ base: 12, md: 3 }}>
<Stack style={{ position: 'sticky', top: 16 }}>
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.summary', 'Summary')}
</Text>
<Stack gap={6}>
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
value={
app.submittedAt
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
: undefined
}
/>
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
</Stack>
</Paper>
{/* Eligibility, checked and shown — not applied invisibly. */}
{eligibility.length > 0 && (
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.eligibility', 'Eligibility')}
</Text>
<Stack gap="xs">
{eligibility.map((rule) => (
<Group key={rule.id} gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon
size={18}
radius="xl"
variant="light"
color={
rule.status === 'pass'
? 'teal'
: rule.status === 'fail'
? 'red'
: 'gray'
}
>
{rule.status === 'pass' ? (
<IconCheck size={11} />
) : rule.status === 'fail' ? (
<IconX size={11} />
) : (
<IconQuestionMark size={11} />
)}
</ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text size="xs" fw={500}>
{rule.label}
</Text>
<Text size="xs" c="dimmed">
{rule.actual}
</Text>
</div>
</Group>
))}
</Stack>
</Paper>
)}
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
{t('review.statusTimeline', 'Progress')}
</Text>
<Timeline bulletSize={12} lineWidth={2} active={data.history.length}>
{data.history.slice(-5).map((entry) => (
<Timeline.Item
key={entry.id}
title={
<Text size="xs" fw={600}>
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
</Text>
}
>
<Text size="xs" c="dimmed">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
</Text>
</Timeline.Item>
))}
</Timeline>
</Paper>
</Stack>
</Grid.Col>
{/* Zone 2 — the application itself. */}
<Grid.Col span={{ base: 12, md: railOpen ? 6 : 9 }}>
<Tabs defaultValue={sections[0]}>
<Tabs.List mb="md">
{/* Tabs with nothing behind them are not rendered at all. */}
{sections.includes('overview') && formSections.length > 0 && (
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
)}
{sections.includes('financials') && (
<Tabs.Tab value="financials">{t('review.tabs.financials', 'Financials')}</Tabs.Tab>
)}
{sections.includes('documents') && (
<Tabs.Tab value="documents">
{t('review.tabs.documents', 'Documents')} ({data.attachments.length})
</Tabs.Tab>
)}
{sections.includes('staff') && data.staff.length > 0 && (
<Tabs.Tab value="staff">
{t('review.tabs.staff', 'Staff')} ({data.staff.length})
</Tabs.Tab>
)}
{sections.includes('inspection') && app.licenseType?.inspectionRequired && (
<Tabs.Tab value="inspection">{t('review.tabs.inspection', 'Inspection')}</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
<Stack>
{formSections.map(([sectionKey, values]) => (
<Card withBorder key={sectionKey} padding="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm" tt="capitalize">
{sectionKey.replace(/([A-Z])/g, ' $1')}
</Text>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={Boolean(flags[sectionKey])}
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
/>
</Group>
<Table withTableBorder>
<Table.Tbody>
{Object.entries(values ?? {}).map(([k, v]) => (
<Table.Tr key={k}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{k}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{v === null ? '—' : String(v)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{flags[sectionKey] && (
<TextInput
mt="xs"
size="xs"
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
value={flags[sectionKey].remark}
onChange={(e) =>
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark: e.currentTarget.value },
}))
}
/>
)}
</Card>
))}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="financials">
<Paper withBorder p="md">
{needsCapital ? (
<>
<NumberInput
label={t('review.verifiedCapital', 'Verified capital (ETB)')}
description={
threshold
? t('review.capitalHint', {
min: threshold.toLocaleString(i18n.language),
defaultValue:
'Minimum {{min}} — check against the bank letter',
})
: t('review.capitalHintNoMin', 'Checked against the bank letter')
}
value={capital ?? ''}
onChange={(v) => setCapital(Number(v) || undefined)}
thousandSeparator=","
error={
capital !== undefined && threshold && capital < threshold
? t('review.belowMinimum', {
min: threshold.toLocaleString(i18n.language),
defaultValue: 'Below the {{min}} minimum',
})
: undefined
}
/>
<Text size="xs" c="dimmed" mt="xs">
{t('review.declared', 'Applicant declared')}{' '}
{app.capitalAmountDeclared
? Number(app.capitalAmountDeclared).toLocaleString(i18n.language)
: '—'}
</Text>
</>
) : (
<Text size="sm" c="dimmed">
{t('review.capitalLocked', 'Capital can no longer be edited at this stage.')}
</Text>
)}
</Paper>
</Tabs.Panel>
<Tabs.Panel value="documents">
<DocumentsTab
applicationId={id}
attachments={data.attachments}
requirements={requirements?.documentRequirements ?? []}
flags={documentFlags}
onToggleFlag={(key) => toggleFlag('DOCUMENT', key)}
onFlagRemark={(key, remark) =>
setFlags((p) => ({ ...p, [key]: { ...p[key], remark } }))
}
/>
</Tabs.Panel>
<Tabs.Panel value="staff">
<Card withBorder padding="md">
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('review.role', 'Role')}</Table.Th>
<Table.Th>{t('review.name', 'Name')}</Table.Th>
<Table.Th>{t('review.evidence', 'Evidence')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.staff.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>
<Text size="xs">{member.roleKey}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{member.fullName}</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{(member.documents ?? []).map((doc) => (
<Badge key={doc.id} size="xs" variant="light">
{doc.documentKey}
</Badge>
))}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
<Tabs.Panel value="inspection">
<Paper withBorder p="md">
{inspections.length === 0 ? (
<Text size="sm" c="dimmed">
{t('review.noInspections', 'No inspection has been scheduled yet.')}
</Text>
) : (
<Stack gap="xs">
{inspections.map((inspection) => (
<Group key={inspection.id} justify="space-between">
<div>
<Text size="sm">
{inspection.scheduledDate
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
: t('review.unscheduled', 'Not scheduled')}
</Text>
{inspection.findings && (
<Text size="xs" c="dimmed">
{inspection.findings}
</Text>
)}
</div>
<Badge
variant="light"
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
>
{inspection.result ?? inspection.status}
</Badge>
</Group>
))}
</Stack>
)}
</Paper>
</Tabs.Panel>
</Tabs>
{status === 'PAYMENT_PENDING' && (
<Alert mt="md" color="yellow" icon={<IconAlertTriangle size={16} />}>
{t('review.awaitingPayment', {
amount: app.feeAmount,
currency: app.feeCurrency,
defaultValue: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
})}
</Alert>
)}
</Grid.Col>
{/* Zone 3 — activity and audit trail. */}
{railOpen && (
<Grid.Col span={{ base: 12, md: 3 }}>
<ActivityRail detail={data} />
</Grid.Col>
)}
</Grid>
<DecisionBar
status={status}
assigneeName={app.assignedOfficerId ? t('review.assigned', 'Assigned') : null}
sla={sla}
actions={actions}
busyAction={busyAction}
onAction={handleAction}
/>
<DecisionConfirmModal
action={pendingAction}
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
applicationNumber={app.applicationNumber}
flaggedDocuments={flagged.map(([key]) => key)}
officers={officers}
submitting={Boolean(busyAction)}
onClose={() => setPendingAction(null)}
onConfirm={submitDecision}
/>
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
>
<Stack>
<TextInput
type="datetime-local"
label={t('review.dateTime', 'Date and time')}
value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Tooltip
label={t('review.pickDate', 'Pick a date and time first')}
disabled={Boolean(inspectionDate)}
>
<span>
<button
type="button"
hidden
aria-hidden
/>
</span>
</Tooltip>
<ActionIcon
variant="filled"
size="lg"
disabled={!inspectionDate}
aria-label={t('review.schedule', 'Schedule')}
onClick={() =>
run(async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: new Date(inspectionDate).toISOString(),
}).unwrap();
setInspectionOpen(false);
}, t('review.done.scheduled', 'Inspection scheduled'))
}
>
<IconCheck size={18} />
</ActionIcon>
</Group>
</Stack>
</Modal>
<Modal
opened={resultOpen}
onClose={() => setResultOpen(false)}
title={t('review.inspectionResult', 'Inspection result')}
>
<Stack>
<Textarea
label={t('review.findings', 'Findings')}
withAsterisk
value={findings}
onChange={(e) => setFindings(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group grow>
<ActionIcon
variant="light"
color="teal"
size="lg"
disabled={!findings.trim() || !pendingInspection}
aria-label={t('review.passed', 'Passed')}
onClick={() =>
run(async () => {
// Guarded by the disabled state above; narrowing it here
// keeps that guarantee in the type system too.
if (!pendingInspection) return;
await recordResult({
inspectionId: pendingInspection.id,
applicationId: id,
result: 'PASSED',
findings,
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionPassed', 'Inspection passed'))
}
>
<IconCheck size={18} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="lg"
disabled={!findings.trim() || !pendingInspection}
aria-label={t('review.failed', 'Failed')}
onClick={() =>
run(async () => {
// Guarded by the disabled state above; narrowing it here
// keeps that guarantee in the type system too.
if (!pendingInspection) return;
await recordResult({
inspectionId: pendingInspection.id,
applicationId: id,
result: 'FAILED',
findings,
}).unwrap();
setResultOpen(false);
}, t('review.done.inspectionFailed', 'Inspection failed'))
}
>
<IconX size={18} />
</ActionIcon>
</Group>
</Stack>
</Modal>
</Container>
);
}
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} ta="right" truncate>
{value || '—'}
</Text>
</Group>
);
}
export default LicenseReviewPage;

View File

@@ -0,0 +1,152 @@
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
export type SavedViewId =
| 'unassigned'
| 'mine'
| 'awaitingApplicant'
| 'overdue'
| 'readyToIssue'
| 'all';
export interface SavedView {
id: SavedViewId;
labelKey: string;
/** Which count from `/counts` labels the tab. */
countKey: keyof QueueCounts;
/** Filters this view pins. The user's own facets layer on top. */
filter: Partial<QueueFilter>;
/** Which list endpoint backs it. */
source: 'queue' | 'mine' | 'all';
}
const AWAITING_APPLICANT: LicenseStatus[] = ['RESUBMIT_REQUIRED'];
const READY_TO_ISSUE: LicenseStatus[] = ['PAYMENT_CONFIRMED'];
/**
* The officer's saved views.
*
* Replaces a two-option SegmentedControl (Unclaimed / Mine) that could not
* express the questions officers actually ask — what is late, what is waiting
* on the applicant, what is ready to issue. Each is a filter preset over the
* same grid rather than a separate screen.
*/
export const SAVED_VIEWS: SavedView[] = [
{
id: 'unassigned',
labelKey: 'queue.views.unassigned',
countKey: 'unassigned',
filter: {},
source: 'queue',
},
{
id: 'mine',
labelKey: 'queue.views.mine',
countKey: 'mine',
filter: {},
source: 'mine',
},
{
id: 'awaitingApplicant',
labelKey: 'queue.views.awaitingApplicant',
countKey: 'awaitingApplicant',
filter: { status: AWAITING_APPLICANT },
source: 'all',
},
{
id: 'overdue',
labelKey: 'queue.views.overdue',
countKey: 'overdue',
filter: { overdue: true },
source: 'all',
},
{
id: 'readyToIssue',
labelKey: 'queue.views.readyToIssue',
countKey: 'readyToIssue',
filter: { status: READY_TO_ISSUE },
source: 'all',
},
{
id: 'all',
labelKey: 'queue.views.all',
countKey: 'all',
filter: {},
source: 'all',
},
];
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
export function readLastView(): SavedViewId {
try {
const stored = localStorage.getItem(LAST_VIEW_KEY) as SavedViewId | null;
return SAVED_VIEWS.some((v) => v.id === stored) ? (stored as SavedViewId) : DEFAULT_VIEW;
} catch {
return DEFAULT_VIEW;
}
}
export function writeLastView(id: SavedViewId): void {
try {
localStorage.setItem(LAST_VIEW_KEY, id);
} catch {
// Not persisting the last view is cosmetic; never break the page for it.
}
}
// ------------------------------------------------------------ URL round-trip
/**
* Reads the user's facets out of the query string.
*
* Filters live in the URL so a filtered queue is a shareable link — "here are
* the six overdue MTO applications" should be something an officer can paste
* into a message, not a state they describe in prose.
*/
export function filterFromSearchParams(params: URLSearchParams): QueueFilter {
const filter: QueueFilter = {};
const search = params.get('q');
if (search) filter.search = search;
const type = params.get('type');
if (type) filter.licenseTypeId = type;
const status = params.get('status');
if (status) filter.status = status.split(',') as LicenseStatus[];
const assignee = params.get('assignee');
if (assignee) filter.assignee = assignee;
const from = params.get('from');
if (from) filter.submittedFrom = from;
const to = params.get('to');
if (to) filter.submittedTo = to;
const sortBy = params.get('sort');
if (sortBy) filter.sortBy = sortBy as QueueFilter['sortBy'];
const sortDir = params.get('dir');
if (sortDir === 'ASC' || sortDir === 'DESC') filter.sortDir = sortDir;
const page = params.get('page');
if (page) {
const parsed = Number(page);
if (Number.isFinite(parsed) && parsed > 0) filter.skip = undefined;
}
return filter;
}
/** Inverse of {@link filterFromSearchParams}. Omits defaults to keep URLs short. */
export function searchParamsFromFilter(
filter: QueueFilter,
view: SavedViewId,
page: number,
): URLSearchParams {
const params = new URLSearchParams();
if (view !== DEFAULT_VIEW) params.set('view', view);
if (filter.search) params.set('q', filter.search);
if (filter.licenseTypeId) params.set('type', filter.licenseTypeId);
if (filter.status?.length) params.set('status', filter.status.join(','));
if (filter.assignee) params.set('assignee', filter.assignee);
if (filter.submittedFrom) params.set('from', filter.submittedFrom);
if (filter.submittedTo) params.set('to', filter.submittedTo);
if (filter.sortBy) params.set('sort', filter.sortBy);
if (filter.sortDir && filter.sortDir !== 'ASC') params.set('dir', filter.sortDir);
if (page > 1) params.set('page', String(page));
return params;
}

View File

@@ -0,0 +1,89 @@
import type { LicenseApplication } from '@ema-platform/api';
/** Amber once this much of the window has been consumed. */
const WARNING_RATIO = 0.7;
const HOUR_MS = 60 * 60 * 1000;
export interface SlaState {
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
/** Mantine colour. Always paired with `label` — never colour alone. */
color: string;
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
label: string;
/** The full explanation, including the target, for the tooltip. */
tooltip: string;
/** Fraction of the window used, clamped to 0..1. */
ratio: number;
}
function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h';
if (hours < 48) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* How an application is tracking against its licence type's SLA.
*
* Types with no `slaHours` are untracked rather than instantly overdue — the
* authority has not set a target for them, which is not the same as missing
* one. Decided applications stop the clock: an approval that took three weeks
* is history, not an outstanding breach.
*/
export function computeSla(
application: LicenseApplication,
now: number = Date.now(),
): SlaState {
const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt;
if (!slaHours || !submittedAt) {
return {
state: 'untracked',
color: 'gray',
label: '—',
tooltip: 'No turnaround target is set for this licence type.',
ratio: 0,
};
}
const submitted = new Date(submittedAt).getTime();
const target = submitted + slaHours * HOUR_MS;
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
if (application.decidedAt) {
const met = elapsed <= window;
return {
state: 'decided',
color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed',
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
ratio,
};
}
const remaining = target - now;
if (remaining < 0) {
return {
state: 'breached',
color: 'red',
label: `Overdue ${formatDuration(remaining)}`,
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
ratio: 1,
};
}
const used = elapsed / window;
return {
state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`,
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
ratio,
};
}

View File

@@ -0,0 +1,100 @@
import { useEffect } from 'react';
/**
* True when the user is typing, so a shortcut must not steal the keystroke.
*
* Without this, typing a company name into the search box would jump rows on
* every "j" and try to claim on every "c".
*/
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target.isContentEditable
);
}
interface QueueKeyboardHandlers {
onNext: () => void;
onPrevious: () => void;
onOpen: () => void;
onClaim: () => void;
onEscape: () => void;
onHelp: () => void;
/** Disabled while a modal or drawer owns the keyboard. */
enabled?: boolean;
}
/**
* Queue keyboard navigation: j/k to move, Enter to open, c to claim.
*
* Officers work through a queue one row at a time all day; reaching for the
* mouse for each is the slow path. Modifier combinations are ignored so
* browser and OS shortcuts keep working.
*/
export function useQueueKeyboard({
onNext,
onPrevious,
onOpen,
onClaim,
onEscape,
onHelp,
enabled = true,
}: QueueKeyboardHandlers): void {
useEffect(() => {
if (!enabled) return;
const handler = (event: KeyboardEvent) => {
if (isTyping(event.target)) {
// Esc still works while typing — it is how you get out of the field.
if (event.key === 'Escape') onEscape();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
switch (event.key) {
case 'j':
event.preventDefault();
onNext();
break;
case 'k':
event.preventDefault();
onPrevious();
break;
case 'Enter':
event.preventDefault();
onOpen();
break;
case 'c':
event.preventDefault();
onClaim();
break;
case 'Escape':
onEscape();
break;
case '?':
event.preventDefault();
onHelp();
break;
default:
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [enabled, onNext, onPrevious, onOpen, onClaim, onEscape, onHelp]);
}
/** Rows shown in the `?` cheatsheet. */
export const KEYBOARD_SHORTCUTS: Array<{ keys: string; labelKey: string }> = [
{ keys: '⌘K', labelKey: 'shortcuts.commandPalette' },
{ keys: 'j / k', labelKey: 'shortcuts.moveRow' },
{ keys: 'Enter', labelKey: 'shortcuts.openRow' },
{ keys: 'c', labelKey: 'shortcuts.claimRow' },
{ keys: 'Esc', labelKey: 'shortcuts.dismiss' },
{ keys: '?', labelKey: 'shortcuts.help' },
];

View File

@@ -123,7 +123,7 @@ function TreeNode({
)}
</Box>
)}
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
<IconMapPin
size={14}
stroke={1.5}

View File

@@ -1,183 +1,195 @@
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Button,
Card,
Center,
Container,
Grid,
Group,
Paper,
Loader,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import {
IconArrowRight,
IconCircleCheck,
IconClockHour4,
IconShieldOff,
IconStack2,
IconTruck,
IconUsers,
IconShip,
} from '@tabler/icons-react';
import { MOCK_FF_APPLICATIONS } from '../../freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
import { MOCK_SA_APPLICATIONS } from '../../shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
import { MOCK_COMBINED_APPLICATIONS } from '../../combined-license/pages/CombinedLicenseQueuePage';
import { MOCK_JV_APPLICATIONS } from '../../joint-investment-license/pages/JointInvestmentLicenseQueuePage';
import { MOCK_MTO_APPLICATIONS } from '../../mto-license/pages/MtoLicenseQueuePage';
import { MOCK_WAIVER_APPLICATIONS } from '../../waiver/pages/WaiverQueuePage';
const STATUS_COLOR: Record<string, string> = {
Submitted: 'gray',
'Under Review': 'blue',
'Under Evaluation': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Resubmit Required': 'orange',
'Certificate Issued': 'green',
Completed: 'green',
};
const LICENSE_LINES = [
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', icon: IconTruck, apps: MOCK_FF_APPLICATIONS },
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', icon: IconShip, apps: MOCK_SA_APPLICATIONS },
{ key: 'combined', label: 'Combined License', route: '/combined-license', icon: IconStack2, apps: MOCK_COMBINED_APPLICATIONS },
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', icon: IconUsers, apps: MOCK_JV_APPLICATIONS },
{ key: 'mto', label: 'MTO License', route: '/mto-license', icon: IconTruck, apps: MOCK_MTO_APPLICATIONS },
{ key: 'waiver', label: 'Waiver', route: '/waiver', icon: IconShieldOff, apps: MOCK_WAIVER_APPLICATIONS },
] as const;
const IN_PROGRESS_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Resubmit Required']);
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: typeof IconTruck;
color: string;
}) {
return (
<Paper p="lg" radius="lg" withBorder>
<ThemeIcon size={46} radius="md" variant="light" color={color}>
<Icon size={22} />
</ThemeIcon>
<Text fz={30} fw={800} mt="md" lh={1.1}>
{value}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{label}
</Text>
</Paper>
);
}
STATUS_COLORS,
STATUS_LABELS,
useGetAssignedToMeQuery,
useGetQueueQuery,
type LicenseStatus,
} from '@ema-platform/api';
/**
* Logistics department overview.
*
* Every figure is derived from the licence applications actually in the
* system. This previously summed six hardcoded arrays, so the department head
* saw counts for applications that had never been filed.
*/
export function LogisticsHeadDashboardPage() {
const navigate = useNavigate();
const queue = useGetQueueQuery();
const mine = useGetAssignedToMeQuery();
const allApps = LICENSE_LINES.flatMap((line) =>
line.apps.map((a) => ({ ...a, _line: line.label, _route: line.route }))
);
if (queue.isLoading || mine.isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
const stats = {
total: allApps.length,
inProgress: allApps.filter((a) => IN_PROGRESS_STATUSES.has(a.status)).length,
issued: allApps.filter((a) => ISSUED_STATUSES.has(a.status)).length,
rejected: allApps.filter((a) => a.status === 'Rejected').length,
};
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
const recent = [...allApps]
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
.slice(0, 6);
const byStatus = all.reduce<Record<string, number>>((acc, app) => {
acc[app.status] = (acc[app.status] ?? 0) + 1;
return acc;
}, {});
const stats = [
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
{ label: 'In progress', value: inProgress.length, color: 'indigo' },
{
label: 'Awaiting payment',
value: byStatus['PAYMENT_PENDING'] ?? 0,
color: 'yellow',
},
{
label: 'Needs applicant action',
value: byStatus['RESUBMIT_REQUIRED'] ?? 0,
color: 'orange',
},
];
const recent = [...all]
.sort((a, b) =>
(b.submittedAt ?? b.createdAt).localeCompare(a.submittedAt ?? a.createdAt),
)
.slice(0, 8);
return (
<Stack gap="xl">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
<IconStack2 size={24} />
</ThemeIcon>
<Stack gap={0}>
<Title order={2}>Logistics Licensing Department</Title>
<Text c="dimmed" size="sm">
Overview across freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications
</Text>
</Stack>
</Group>
</Group>
<Container size="xl" py="md">
<Title order={3} mb="xs">
Logistics overview
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the department.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<StatCard label="Total Applications" value={stats.total} icon={IconStack2} color="indigo" />
<StatCard label="In Progress" value={stats.inProgress} icon={IconClockHour4} color="yellow" />
<StatCard label="Certificates Issued" value={stats.issued} icon={IconCircleCheck} color="teal" />
<StatCard label="Rejected" value={stats.rejected} icon={IconShieldOff} color="red" />
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (
<Card withBorder key={stat.label} padding="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
{stat.value}
</Text>
</Card>
))}
</SimpleGrid>
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Text fw={700} fz="lg" mb="md">Recent Applications</Text>
<Stack gap={0}>
{recent.map((a, i) => (
<Group
key={a.id}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
cursor: 'pointer',
}}
onClick={() => navigate(`${a._route}/${a.id}`)}
>
<Stack gap={0}>
<Text size="sm" fw={600}>{a.companyName}</Text>
<Text size="xs" c="dimmed">{a._line} {a.id}</Text>
</Stack>
<Badge variant="light" color={STATUS_COLOR[a.status] ?? 'gray'} radius="sm">
{a.status}
</Badge>
</Group>
))}
</Stack>
</Paper>
<Grid>
<Grid.Col span={{ base: 12, md: 7 }}>
<Card withBorder padding={0} radius="md">
<Group justify="space-between" p="md" pb="xs">
<Text fw={600} size="sm">
Most recent
</Text>
<Text
size="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
View all <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
{recent.length === 0 ? (
<Center py="xl">
<Text size="sm" c="dimmed">
No licence applications yet.
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recent.map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/licence-review/${app.id}`)}
>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[app.status as LicenseStatus]}
>
{STATUS_LABELS[app.status as LicenseStatus]}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">License Queues</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
All
<IconArrowRight size={14} />
</Group>
</Anchor>
</Group>
<Stack gap="sm">
{LICENSE_LINES.map((line) => (
<Button
key={line.key}
fullWidth
variant="light"
leftSection={<line.icon size={16} />}
justify="space-between"
rightSection={<Badge size="sm" variant="filled" color="indigo">{line.apps.length}</Badge>}
onClick={() => navigate(line.route)}
>
{line.label}
</Button>
))}
</Stack>
</Paper>
<Grid.Col span={{ base: 12, md: 5 }}>
<Card withBorder padding="md" radius="md">
<Text fw={600} size="sm" mb="sm">
By status
</Text>
{Object.keys(byStatus).length === 0 ? (
<Text size="sm" c="dimmed">
Nothing in the pipeline.
</Text>
) : (
<Stack gap="xs">
{Object.entries(byStatus)
.sort((a, b) => b[1] - a[1])
.map(([status, count]) => (
<Group key={status} justify="space-between">
<Badge
variant="light"
color={STATUS_COLORS[status as LicenseStatus]}
>
{STATUS_LABELS[status as LicenseStatus] ?? status}
</Badge>
<Text size="sm" fw={600}>
{count}
</Text>
</Group>
))}
</Stack>
)}
</Card>
</Grid.Col>
</Grid>
</Stack>
</Container>
);
}
export default LogisticsHeadDashboardPage;

View File

@@ -1,381 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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}
<Container size="lg" py="xl">
<FeatureUnavailable
title="Medical verification"
description="Medical certificate verification is not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export default MedicalVerificationPage;

View File

@@ -1,422 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShip,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type LicenseStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Inspection Pending'
| 'Inspection Completed'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Payment Pending'
| 'Payment Confirmed'
| 'Certificate Issued';
export interface MtoApplication {
id: string;
companyName: string;
tinNumber: string;
commercialRegNumber: string;
businessAddress: string;
bankName: string;
capitalAmount: number;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
docsComplete: boolean;
}
export const MOCK_MTO_APPLICATIONS: MtoApplication[] = [
{
id: 'MTO-2024-001',
companyName: 'Ethio Multimodal Transport PLC',
tinNumber: 'TIN-0098231',
commercialRegNumber: 'CR-903112',
businessAddress: 'Addis Ababa, Bole Sub-city',
bankName: 'Commercial Bank of Ethiopia',
capitalAmount: 3200000,
status: 'Inspection Pending',
submittedDate: '2024-03-15',
approvalDate: null,
expiryDate: null,
remarks: 'Awaiting physical inspection of terminal and warehouse facilities.',
docsComplete: true,
},
{
id: 'MTO-2024-002',
companyName: 'Blue Nile Logistics & Terminals Ltd',
tinNumber: 'TIN-0076543',
commercialRegNumber: 'CR-812098',
businessAddress: 'Dire Dawa',
bankName: 'Awash Bank',
capitalAmount: 2750000,
status: 'Submitted',
submittedDate: '2024-04-05',
approvalDate: null,
expiryDate: null,
remarks: '',
docsComplete: false,
},
{
id: 'MTO-2023-018',
companyName: 'Horn of Africa Freight Terminals PLC',
tinNumber: 'TIN-0045210',
commercialRegNumber: 'CR-701244',
businessAddress: 'Addis Ababa, Kirkos Sub-city',
bankName: 'Zemen Bank',
capitalAmount: 4100000,
status: 'Certificate Issued',
submittedDate: '2023-09-20',
approvalDate: '2023-10-25',
expiryDate: '2024-10-25',
remarks: 'All requirements verified. Certificate issued.',
docsComplete: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
'Inspection Pending': 'grape',
'Inspection Completed': 'indigo',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Certificate Issued': 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: MtoApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
{ label: 'Business Address', value: app.businessAddress },
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Bank Confirmation & Deposit Evidence', ok: app.docsComplete },
{ label: 'Terminal Lease / Title Document', ok: app.docsComplete },
{ label: 'Vehicle Registration / Rental Documents', ok: app.docsComplete },
{ label: 'Manager CV & Qualification Documents', ok: app.docsComplete },
{ label: 'Insurance & Customs Bond Documents', ok: app.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function MtoLicenseQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<MtoApplication[]>(MOCK_MTO_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<MtoApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<MtoApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/mto?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: LicenseStatus =
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved — pending payment.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation' || a.status === 'Inspection Pending' || a.status === 'Inspection Completed').length,
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/mto-license/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>MTO License Queue</Title>
<Text fz="sm" c="dimmed">Review and process Multimodal Transport Operator License applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Bank Letter Amount</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/mto-license/${id}`)}
/>
</Stack>
);
}
export const MTO_ICON = IconShip;

View File

@@ -1,323 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCamera,
IconCertificate,
IconCircleCheck,
IconClipboardCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconShip,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_MTO_APPLICATIONS, STATUS_COLOR } from './MtoLicenseQueuePage';
import type { MtoApplication, LicenseStatus } from './MtoLicenseQueuePage';
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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().split('T')[0];
}
const DOCS = [
{ key: 'bankLetter', label: 'Bank Confirmation & Deposit Evidence', fileName: 'bank_confirmation.pdf', icon: IconFileDescription, required: true },
{ key: 'terminalDoc', label: 'Terminal Lease / Title Document', fileName: 'terminal_doc.pdf', icon: IconId, required: true },
{ key: 'vehicleDoc', label: 'Vehicle Registration / Rental Documents', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
{ key: 'managerDocs', label: 'Manager CV & Qualification Documents', fileName: 'manager_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'insuranceDocs', label: 'Insurance & Customs Bond Documents', fileName: 'insurance_bond.pdf', icon: IconShieldCheck, required: true },
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
];
export function MtoLicenseReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<MtoApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<MtoApplication | null>(null);
const [status, setStatus] = useState<LicenseStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-licenses/mto/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_MTO_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
const isInspection = status === 'Inspection Pending' || status === 'Inspection Completed';
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as LicenseStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/mto-license')}>
Back to Queue
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
Back to Queue
</Button>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Multimodal Transport Operator License</Text>
</div>
</Group>
{status === 'Certificate Issued' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{status === 'Inspection Pending' && (
<Alert icon={<IconClipboardCheck size={17} />} color="grape" title="Inspection Required">
A physical inspection of the applicant's terminal, warehouse, trucks, office, and equipment is required before this application can proceed. An Inspector must complete the site visit.
</Alert>
)}
{status === 'Inspection Completed' && (
<Alert icon={<IconClipboardCheck size={17} />} color="indigo" title="Inspection Completed">
The physical inspection of the applicant's terminal, warehouse, trucks, office, and equipment was completed by an Inspector.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
<InfoRow label="Business Address" value={app.businessAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Financial Capacity</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Bank Name" value={app.bankName} />
<InfoRow label="Paid-up Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
{isInspection && (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Inspection</Text>
<Stack gap={6}>
<InfoRow label="Inspection Status" value={status} />
<InfoRow label="Scope" value="Terminal, Warehouse, Trucks, Office, Equipment" />
</Stack>
</Paper>
)}
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{DOCS.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{status === 'Certificate Issued' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">Issued Certificate Multimodal Transport Operator License</Text>
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Multimodal Transport Operator License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
{(selectedStatus === 'Inspection Pending' || selectedStatus === 'Inspection Completed') && (
<Alert icon={<IconClipboardCheck size={16} />} color="grape" variant="light">
{selectedStatus === 'Inspection Pending'
? 'A physical inspection of the terminal, warehouse, trucks, office, and equipment is required. An Inspector must complete this before further processing.'
: 'This confirms the physical inspection of the terminal, warehouse, trucks, office, and equipment was completed by an Inspector.'}
</Alert>
)}
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,17 +1,15 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Divider,
Center,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Switch,
Table,
@@ -19,400 +17,365 @@ import {
TextInput,
ThemeIcon,
Title,
rem,
Tooltip,
} from '@mantine/core';
import {
IconBook2,
IconCertificate,
IconCheck,
IconAlertTriangle,
IconCreditCard,
IconEdit,
IconId,
IconInfoCircle,
IconShieldCheck,
IconLock,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
extractErrorMessage,
localized,
useGetLicenseTypesQuery,
useGetPaymentCapabilitiesQuery,
useUpdateLicenseFeesMutation,
} from '@ema-platform/api';
import type { LicenseType } from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CertFee {
id: string;
certType: string;
icon: typeof IconBook2;
color: string;
description: string;
fees: { label: string; amount: number; editable: boolean }[];
enabled: boolean;
/**
* Licence fee configuration.
*
* The amounts live on the licence type itself, which is what the workflow
* reads when it raises a payment — so what is edited here is the same value
* the applicant is charged, not a parallel copy of it.
*
* Saving is guarded server-side by `can:update:license-type`; an officer
* without that permission can read the figures but the save is refused.
*/
function feeText(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return '—';
const value = Number(amount);
if (!Number.isFinite(value)) return '—';
return `${value.toLocaleString('en-US')} ${currency}`;
}
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 { data, isLoading, error } = useGetLicenseTypesQuery();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [editing, setEditing] = useState<LicenseType | null>(null);
const [editingCert, setEditingCert] = useState<CertFee | null>(null);
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
const totalCombined = certFees
.filter((c) => c.enabled)
.flatMap((c) => c.fees)
.reduce((s, f) => s + f.amount, 0);
if (error) {
return (
<Alert
color="red"
icon={<IconAlertTriangle size={18} />}
title="Could not load licence types"
>
<Text size="sm">{extractErrorMessage(error)}</Text>
</Alert>
);
}
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));
};
const types = [...(data?.items ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
return (
<Stack gap="md">
{/* Header */}
<Stack gap="lg">
<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>
<Title order={3}>Payment configuration</Title>
<Text size="sm" c="dimmed" mt={4}>
What each licence costs. Applicants are charged after approval, and
the amount is fixed onto the application at that moment.
</Text>
</div>
<Badge size="lg" variant="light" color="blue">
Combined Total: ETB {totalCombined.toFixed(2)}
</Badge>
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
</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>
<Alert
variant="light"
color="blue"
radius="md"
icon={<IconInfoCircle size={18} />}
>
<Text size="sm">
A change applies to applications approved from now on. Anything
already approved keeps the amount it was quoted, so an edit here can
never alter what an applicant has already been asked to pay.
</Text>
</Alert>
<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 }}>
<Card withBorder radius="md" padding={0}>
<Table.ScrollContainer minWidth={820}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Licence type</Table.Th>
<Table.Th>New application</Table.Th>
<Table.Th>Renewal</Table.Th>
<Table.Th>Charged?</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{types.map((type) => (
<Table.Tr key={type.id}>
<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>
<Text size="sm" fw={600}>
{localized(type.name)}
</Text>
<Text size="xs" c="dimmed" ff="monospace">
{type.key}
</Text>
</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>
<Text size="sm" fw={500}>
{feeText(type.feeNewApplication, type.feeCurrency)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
{type.feeNewApplication === null ? (
// No charge at all, so "same as new" would be noise.
<Text size="sm" c="dimmed">
</Text>
) : type.feeRenewal === null ? (
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
<Text size="sm" c="dimmed">
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
<Text span size="xs" c="dimmed">
(same as new)
</Text>
</Text>
</Tooltip>
) : (
<Text size="sm" fw={500}>
{feeText(type.feeRenewal, type.feeCurrency)}
</Text>
)}
</Table.Td>
<Table.Td>
<Switch
checked={cert.enabled}
onChange={() => handleToggleCert(cert.id)}
size="sm"
color="teal"
label={cert.enabled ? 'Active' : 'Disabled'}
/>
{type.issuesCertificate ? (
<Badge variant="light" color="teal" size="sm">
On approval
</Badge>
) : (
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
<Badge variant="light" color="gray" size="sm">
Not charged
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
<IconEdit size={15} />
</ActionIcon>
<Table.Td align="right">
<Button
size="xs"
variant="light"
leftSection={<IconEdit size={14} />}
onClick={() => setEditing(type)}
>
Edit
</Button>
</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>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
{/* 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>
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
{/* 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}
/>
<FeeEditModal licenseType={editing} onClose={() => setEditing(null)} />
</Stack>
);
}
/**
* How payments are actually collected. Read-only on purpose: these come from
* the payment service's environment rather than the database, so rendering
* them as editable fields would be a lie.
*/
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
return (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="xs">
<ThemeIcon size="sm" radius="xl" variant="light" color="gray">
<IconLock size={13} />
</ThemeIcon>
<Text fw={600} size="sm">
Payment gateway
</Text>
<Text size="xs" c="dimmed">
Set by the payment service environment not editable here.
</Text>
</Group>
<Group gap="sm">
<Badge variant="light">Telebirr</Badge>
{bypassEnabled ? (
<Tooltip label="ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.">
<Badge variant="light" color="orange">
Test bypass enabled
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray">
Test bypass off
</Badge>
)}
</Group>
</Paper>
);
}
function FeeEditModal({
licenseType,
onClose,
}: {
licenseType: LicenseType | null;
onClose: () => void;
}) {
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
const [newFee, setNewFee] = useState<number | ''>('');
const [renewalFee, setRenewalFee] = useState<number | ''>('');
const [currency, setCurrency] = useState('ETB');
// Held separately from an empty amount: "carries no fee" and "same as new"
// are real configuration states, not blank fields.
const [chargeable, setChargeable] = useState(true);
const [sameAsNew, setSameAsNew] = useState(true);
useEffect(() => {
if (!licenseType) return;
setChargeable(licenseType.feeNewApplication !== null);
setNewFee(
licenseType.feeNewApplication === null
? ''
: Number(licenseType.feeNewApplication),
);
setSameAsNew(licenseType.feeRenewal === null);
setRenewalFee(
licenseType.feeRenewal === null ? '' : Number(licenseType.feeRenewal),
);
setCurrency(licenseType.feeCurrency || 'ETB');
}, [licenseType]);
async function save() {
if (!licenseType) return;
if (chargeable && newFee === '') {
notify.error('Enter a new-application fee, or turn off "carries a fee".');
return;
}
if (chargeable && !sameAsNew && renewalFee === '') {
notify.error('Enter a renewal fee, or charge renewal at the same rate.');
return;
}
try {
await updateFees({
id: licenseType.id,
feeNewApplication: chargeable ? Number(newFee) : null,
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
feeCurrency: currency.trim() || 'ETB',
}).unwrap();
notify.success(`${localized(licenseType.name)} fees updated.`);
onClose();
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not save the fees'));
}
}
return (
<Modal
opened={!!licenseType}
onClose={onClose}
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
centered
radius="lg"
>
{licenseType && (
<Stack gap="md">
{!licenseType.issuesCertificate && (
<Alert
variant="light"
color="gray"
icon={<IconInfoCircle size={16} />}
>
<Text size="sm">
This licence type concludes with an EMA decision and never
reaches a payment stage, so a fee set here stays unused until
that changes.
</Text>
</Alert>
)}
<Switch
checked={chargeable}
onChange={(e) => setChargeable(e.currentTarget.checked)}
label="This licence carries a fee"
description="Turn off for licence types applicants are never charged for."
/>
{chargeable && (
<>
<NumberInput
label="New application fee"
value={newFee}
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
<Switch
checked={sameAsNew}
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
label="Charge renewal at the same rate"
description="Turn off to set a separate renewal fee."
/>
{!sameAsNew && (
<NumberInput
label="Renewal fee"
value={renewalFee}
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
min={0}
max={9_999_999_999}
thousandSeparator=","
allowNegative={false}
decimalScale={2}
/>
)}
<TextInput
label="Currency"
value={currency}
onChange={(e) =>
setCurrency(e.currentTarget.value.toUpperCase())
}
maxLength={8}
/>
</>
)}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button onClick={save} loading={isLoading}>
Save fees
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
export default PaymentConfigPage;

View File

@@ -1,325 +1,127 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Center,
Container,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Loader,
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';
import { IconSearch } from '@tabler/icons-react';
import { useApiQuery } from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
interface Seafarer {
interface ProfileRow {
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';
firstName: string;
middleName?: string;
lastName: string;
gender?: string;
type?: string;
isComplete?: boolean;
profession?: { name?: { en?: string } };
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
}
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
// ---------------------------------------------------------------------------
/**
* Registered seafarer profiles, read from the real profiles endpoint.
*
* This previously listed a hardcoded roster, so the registry showed seafarers
* who had never registered.
*/
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 { data, isLoading } = useApiQuery<{ total: number; items: ProfileRow[] }>({
url: '/profiles',
method: 'GET',
params: { q: 'i=profession,address&t=200' },
});
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 },
];
const items = (data?.items ?? []).filter((p) => {
if (!search.trim()) return true;
const term = search.toLowerCase();
return [p.firstName, p.middleName, p.lastName, p.address?.idNumber]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term));
});
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>
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Seafarer registry</Title>
<Text size="sm" c="dimmed">
{data?.total ?? 0} registered profile{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<TextInput
placeholder="Name or ID number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
</Group>
{/* 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 && (
<Card withBorder padding={0}>
{isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text size="sm" c="dimmed">
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<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.Th>Name</Table.Th>
<Table.Th>Profession</Table.Th>
<Table.Th>ID number</Table.Th>
<Table.Th>Phone</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
</Stack>
</Table.Thead>
<Table.Tbody>
{items.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text size="sm" fw={500}>
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{p.profession?.name?.en ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.idNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{p.address?.primaryPhoneNumber ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
{p.isComplete ? 'Complete' : 'Incomplete'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Container>
);
}
export default SeafarerRegistryPage;

View File

@@ -1,377 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book queue"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
}
export default SeamanBookQueuePage;

View File

@@ -1,419 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShip,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type LicenseStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Payment Pending'
| 'Payment Confirmed'
| 'Certificate Issued';
export interface ShippingAgentApplication {
id: string;
companyName: string;
tinNumber: string;
commercialRegNumber: string;
businessAddress: string;
bankName: string;
capitalAmount: number;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
docsComplete: boolean;
}
export const MOCK_SA_APPLICATIONS: ShippingAgentApplication[] = [
{
id: 'SA-2024-001',
companyName: 'Blue Nile Shipping Agency PLC',
tinNumber: 'TIN-0098765',
commercialRegNumber: 'CR-902001',
businessAddress: 'Addis Ababa, Bole Sub-city',
bankName: 'Commercial Bank of Ethiopia',
capitalAmount: 1650000,
status: 'Under Evaluation',
submittedDate: '2024-03-12',
approvalDate: null,
expiryDate: null,
remarks: 'Terminal agreement and employee documents under review.',
docsComplete: true,
},
{
id: 'SA-2024-002',
companyName: 'Red Sea Maritime Agents Ltd',
tinNumber: 'TIN-0043218',
commercialRegNumber: 'CR-770512',
businessAddress: 'Dire Dawa',
bankName: 'Dashen Bank',
capitalAmount: 1250000,
status: 'Submitted',
submittedDate: '2024-04-05',
approvalDate: null,
expiryDate: null,
remarks: '',
docsComplete: false,
},
{
id: 'SA-2023-018',
companyName: 'Horn of Africa Shipping Agency PLC',
tinNumber: 'TIN-0021456',
commercialRegNumber: 'CR-661120',
businessAddress: 'Addis Ababa, Kirkos Sub-city',
bankName: 'Zemen Bank',
capitalAmount: 1950000,
status: 'Certificate Issued',
submittedDate: '2023-10-11',
approvalDate: '2023-11-08',
expiryDate: '2024-11-08',
remarks: 'All requirements verified. Certificate issued.',
docsComplete: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Certificate Issued': 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: ShippingAgentApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
{ label: 'Business Address', value: app.businessAddress },
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Bank Letter (≥ 1.2M ETB)', ok: app.docsComplete },
{ label: 'Shipping Company Agreement', ok: app.docsComplete },
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
{ label: 'Terminal Agreement / Title Deed', ok: app.docsComplete },
{ label: '4 Employee Profiles (Booking Clerk, Canvasser, Admin, CEO)', ok: app.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function ShippingAgentLicenseQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<ShippingAgentApplication[]>(MOCK_SA_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<ShippingAgentApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<ShippingAgentApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/shipping-agent?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: LicenseStatus =
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved — pending payment.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
return matchSearch && matchStatus;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/shipping-agent-license/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Shipping Agent License Queue</Title>
<Text fz="sm" c="dimmed">Review and process Shipping Agent License applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Bank Letter Amount</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/shipping-agent-license/${id}`)}
/>
</Stack>
);
}
export const SA_ICON = IconShip;

View File

@@ -1,300 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCamera,
IconCertificate,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconShip,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_SA_APPLICATIONS, STATUS_COLOR } from './ShippingAgentLicenseQueuePage';
import type { ShippingAgentApplication, LicenseStatus } from './ShippingAgentLicenseQueuePage';
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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 1);
return d.toISOString().split('T')[0];
}
const DOCS = [
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.2M ETB)', fileName: 'bank_letter.pdf', icon: IconFileDescription, required: true },
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', fileName: 'shipping_agreement.pdf', icon: IconFileDescription, required: true },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', fileName: 'office_doc.pdf', icon: IconId, required: true },
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', fileName: 'terminal_doc.pdf', icon: IconId, required: true },
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile & Work Experience Evidence', fileName: 'booking_clerk_docs.pdf', icon: IconShieldCheck, required: true },
{ key: 'canvasserDocs', label: 'Canvasser Profile & Work Experience Evidence', fileName: 'canvasser_docs.pdf', icon: IconShieldCheck, required: true },
{ key: 'adminDocs', label: 'Administrative Staff Profile & Work Experience Evidence', fileName: 'admin_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'ceoDocs', label: 'CEO/General Manager Profile & Work Experience Evidence', fileName: 'ceo_docs.pdf', icon: IconFileDescription, required: true },
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
];
export function ShippingAgentLicenseReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<ShippingAgentApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<ShippingAgentApplication | null>(null);
const [status, setStatus] = useState<LicenseStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-licenses/shipping-agent/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_SA_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as LicenseStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/shipping-agent-license')}>
Back to Queue
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
Back to Queue
</Button>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Shipping Agent License</Text>
</div>
</Group>
{status === 'Certificate Issued' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
<InfoRow label="Business Address" value={app.businessAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Bank Letter / Capital Evidence</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Bank Name" value={app.bankName} />
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
<InfoRow label="Minimum Required" value="1,200,000 ETB" />
<InfoRow label="Meets Threshold" value={app.capitalAmount >= 1200000 ? 'Yes' : 'No'} />
</SimpleGrid>
</Paper>
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{DOCS.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{status === 'Certificate Issued' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">Issued Certificate Shipping Agent License</Text>
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Shipping Agent License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,165 +1,21 @@
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Button,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconArrowRight,
IconChartBar,
IconCircleCheck,
IconClockHour4,
IconFileDescription,
IconTransferIn,
} from '@tabler/icons-react';
import { MOCK_VESSEL_REGISTRATIONS } from '../../vessel-registration/pages/VesselRegistrationQueuePage';
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: typeof IconAnchor;
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>
</Group>
<Text fz={30} fw={800} mt="md" lh={1.1}>
{value}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{label}
</Text>
</Paper>
);
}
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationHeadDashboardPage() {
const navigate = useNavigate();
const regs = MOCK_VESSEL_REGISTRATIONS;
const stats = {
total: regs.length,
underReview: regs.filter((r) => r.status === 'Under Review' || r.status === 'Pending').length,
approved: regs.filter((r) => r.status === 'Approved').length,
renewalsDue: regs.filter((r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue').length,
};
const recent = [...regs]
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
.slice(0, 5);
return (
<Stack gap="xl">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<Stack gap={0}>
<Title order={2}>Vessel Registration Department</Title>
<Text c="dimmed" size="sm">
Overview of vessel registration and ownership transfer activity
</Text>
</Stack>
</Group>
<Group gap="sm">
<Button variant="light" leftSection={<IconChartBar size={16} />} onClick={() => navigate('/vessel-registration-report')}>
Full Report
</Button>
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration-queue')}>
Open Queue
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<StatCard label="Total Registrations" value={stats.total} icon={IconAnchor} color="blue" />
<StatCard label="Awaiting Review" value={stats.underReview} icon={IconClockHour4} color="yellow" />
<StatCard label="Approved" value={stats.approved} icon={IconCircleCheck} color="teal" />
<StatCard label="Renewals Due / Overdue" value={stats.renewalsDue} icon={IconFileDescription} color="orange" />
</SimpleGrid>
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">Recent Applications</Text>
<Anchor size="sm" fw={600} onClick={() => navigate('/vessel-registration-queue')} style={{ cursor: 'pointer' }}>
<Group gap={4} wrap="nowrap">
View queue
<IconArrowRight size={14} />
</Group>
</Anchor>
</Group>
<Stack gap={0}>
{recent.map((r, i) => (
<Group
key={r.id}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
cursor: 'pointer',
}}
onClick={() => navigate(`/vessel-registration-queue/${r.id}`)}
>
<Stack gap={0}>
<Text size="sm" fw={600}>{r.vesselName}</Text>
<Text size="xs" c="dimmed">{r.id} {r.ownerName}</Text>
</Stack>
<Badge variant="light" color={STATUS_COLOR[r.status] ?? 'gray'} radius="sm">
{r.status}
</Badge>
</Group>
))}
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Text fw={700} fz="lg" mb="md">Quick Links</Text>
<Stack gap="sm">
<Button fullWidth variant="light" leftSection={<IconAnchor size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-queue')}>
Registration Queue
</Button>
<Button fullWidth variant="light" leftSection={<IconTransferIn size={16} />} justify="flex-start" onClick={() => navigate('/vessel-ownership-transfer')}>
Ownership Transfer Queue
</Button>
<Button fullWidth variant="light" leftSection={<IconChartBar size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-report')}>
Registration Report
</Button>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration overview"
description="Vessel registration is not connected to the backend yet, so there are no figures to report."
/>
</Container>
);
}
export default VesselRegistrationHeadDashboardPage;

View File

@@ -1,415 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconFileDescription,
IconSearch,
IconTransferIn,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & mock data (self-contained — portal page has its own copy)
// ---------------------------------------------------------------------------
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
export interface OwnershipTransferRequest {
id: string;
vesselId: string;
vesselName: string;
category: string;
vesselType: string;
currentOwnerName: string;
currentOwnerIdOrTin: string;
currentOwnerPhone: string;
newOwnerName: string;
newOwnerIdOrTin: string;
newOwnerPhone: string;
newOwnerEmail: string;
newOwnerAddress: string;
transferReason: string;
remarks: string;
status: TransferStatus;
submittedDate: string;
approvalDate: string | null;
}
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
{
id: 'OT-2024-001',
vesselId: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
currentOwnerName: 'Abebe Girma',
currentOwnerIdOrTin: 'ET-9812345',
currentOwnerPhone: '+251 911 234 567',
newOwnerName: 'Tigist Haile',
newOwnerIdOrTin: 'ET-7743210',
newOwnerPhone: '+251 922 876 543',
newOwnerEmail: 'tigist.haile@email.com',
newOwnerAddress: 'Bahir Dar, Amhara Region',
transferReason: 'Sale / Purchase',
remarks: 'Vessel sold to new owner. Bill of sale attached.',
status: 'Pending',
submittedDate: '2024-06-01',
approvalDate: null,
},
{
id: 'OT-2024-002',
vesselId: 'VR-2024-002',
vesselName: 'Red Sea Voyager',
category: 'Sea-going Vessel (International)',
vesselType: 'General Cargo',
currentOwnerName: 'Ethio Shipping Lines PLC',
currentOwnerIdOrTin: 'TIN-0045678',
currentOwnerPhone: '+251 115 501 010',
newOwnerName: 'Ethiopian Maritime Transport S.C.',
newOwnerIdOrTin: 'TIN-0078910',
newOwnerPhone: '+251 115 502 020',
newOwnerEmail: 'info@emtsc.et',
newOwnerAddress: 'Addis Ababa, Bole Sub-city',
transferReason: 'Corporate Restructuring',
remarks: 'Merger-related transfer. Court order attached.',
status: 'Under Review',
submittedDate: '2024-05-20',
approvalDate: null,
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
const STATUS_OPTIONS = [
{ value: '', label: 'All Statuses' },
{ value: 'Pending', label: 'Pending' },
{ value: 'Under Review', label: 'Under Review' },
{ value: 'Approved', label: 'Approved' },
{ value: 'Rejected', label: 'Rejected' },
];
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
// Certificates generated on approval
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
const SEAGOING_CERTS = [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
];
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselOwnershipTransferQueuePage() {
const navigate = useNavigate();
const [records, setRecords] = useState<OwnershipTransferRequest[]>([]);
const [filtered, setFiltered] = useState<OwnershipTransferRequest[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selected, setSelected] = useState<OwnershipTransferRequest | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState<string>('');
const [remarks, setRemarks] = useState('');
const [saving, setSaving] = useState(false);
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest[]>();
const [actionTrigger] = useApiMutation<{ success: boolean }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-ownership-transfers', method: 'GET' })
.unwrap()
.then((data) => setRecords(Array.isArray(data) ? data : [data]))
.catch(() => setRecords(MOCK_TRANSFER_REQUESTS));
}, [fetchTrigger]);
useEffect(() => {
let result = records;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((r) =>
r.vesselName.toLowerCase().includes(q) ||
r.id.toLowerCase().includes(q) ||
r.currentOwnerName.toLowerCase().includes(q) ||
r.newOwnerName.toLowerCase().includes(q)
);
}
if (statusFilter) result = result.filter((r) => r.status === statusFilter);
setFiltered(result);
}, [records, search, statusFilter]);
const openDrawer = (req: OwnershipTransferRequest) => { setSelected(req); setDrawerOpen(true); };
const closeDrawer = () => { setDrawerOpen(false); setSelected(null); };
const handleOpenModal = () => {
if (!selected) return;
setNewStatus(selected.status);
setRemarks(selected.remarks ?? '');
setModalOpen(true);
};
const handleAction = async () => {
if (!selected) return;
setSaving(true);
const isApproval = newStatus === 'Approved';
try {
await actionTrigger({
url: `/vessel-ownership-transfers/${selected.id}/status`,
method: 'PATCH',
body: { status: newStatus, remarks, generateCertificates: isApproval },
}).unwrap();
} catch { /* mock mode */ }
const today = new Date().toISOString().split('T')[0];
setRecords((prev) =>
prev.map((r) =>
r.id === selected.id
? { ...r, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : r.approvalDate }
: r
)
);
setSelected((prev) => prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : null);
setModalOpen(false);
setSaving(false);
if (isApproval) {
const certs = selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
notify.success(`Ownership transfer approved. ${certs.length} certificate(s) generated for ${selected.newOwnerName}.`);
} else {
notify.success('Status updated.');
}
};
// Stats
const total = records.length;
const pending = records.filter((r) => r.status === 'Pending').length;
const underReview = records.filter((r) => r.status === 'Under Review').length;
const approved = records.filter((r) => r.status === 'Approved').length;
const isTerminal = selected?.status === 'Approved' || selected?.status === 'Rejected';
return (
<Stack gap="md">
{/* Header */}
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer Queue</Title>
<Text fz="sm" c="dimmed">Review and process vessel ownership transfer requests</Text>
</div>
</Group>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Requests', value: total, color: 'blue' },
{ label: 'Pending', value: pending, color: 'gray' },
{ label: 'Under Review', value: underReview, color: 'yellow' },
{ label: 'Approved', value: approved, color: 'teal' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xl" fw={800} c={`${s.color}.6`}>{s.value}</Text>
<Text fz="xs" c="dimmed">{s.label}</Text>
</Card>
))}
</SimpleGrid>
{/* Filters */}
<Group gap="sm">
<TextInput
placeholder="Search vessel, owner..."
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All Statuses"
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? '')}
clearable
w={160}
/>
</Group>
{/* Table */}
<Paper withBorder radius="md" style={{ overflow: 'hidden' }}>
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Request ID</Table.Th>
<Table.Th>Vessel</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={8}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No transfer requests found.</Text>
</Table.Td>
</Table.Tr>
) : filtered.map((req) => (
<Table.Tr key={req.id}>
<Table.Td><Text fz="sm" fw={600} c="blue.6">{req.id}</Text></Table.Td>
<Table.Td>
<Text fz="sm" fw={600}>{req.vesselName}</Text>
<Text fz="xs" c="dimmed">{req.category}</Text>
</Table.Td>
<Table.Td><Text fz="sm">{req.currentOwnerName}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.newOwnerName}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.transferReason}</Text></Table.Td>
<Table.Td><Text fz="sm">{req.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => openDrawer(req)}>Review</Button>
<Button size="xs" variant="subtle" onClick={() => navigate(`/vessel-ownership-transfer/${req.id}`)}>
Details
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
{/* Drawer */}
<Drawer
opened={drawerOpen}
onClose={closeDrawer}
position="right"
size="md"
title={<Text fw={700}>Transfer Request {selected?.id}</Text>}
>
{selected && (
<Stack gap="md">
<Paper withBorder radius="sm" p="sm">
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={6}>Vessel</Text>
<Text fw={600}>{selected.vesselName}</Text>
<Text fz="sm" c="dimmed">{selected.category} · {selected.vesselType}</Text>
</Paper>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'From (Owner)', value: selected.currentOwnerName },
{ label: 'From (ID/TIN)', value: selected.currentOwnerIdOrTin },
{ label: 'To (New Owner)', value: selected.newOwnerName },
{ label: 'To (ID/TIN)', value: selected.newOwnerIdOrTin },
{ label: 'New Owner Phone', value: selected.newOwnerPhone },
{ label: 'New Owner Email', value: selected.newOwnerEmail || '—' },
{ label: 'New Owner Address', value: selected.newOwnerAddress || '—' },
{ label: 'Transfer Reason', value: selected.transferReason },
{ label: 'Submitted', value: selected.submittedDate },
{ label: 'Current Status', value: selected.status },
].map((row) => (
<div key={row.label}>
<Text fz="xs" c="dimmed">{row.label}</Text>
<Text fz="sm" fw={500}>{row.value}</Text>
</div>
))}
</SimpleGrid>
{selected.remarks && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
<Text fz="xs" c="dimmed" mb={2}>Remarks</Text>
<Text fz="sm">{selected.remarks}</Text>
</Paper>
)}
<Divider />
{/* Certificates preview */}
{selected.status === 'Approved' && (
<Paper withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
<Group gap="xs" mb={6}>
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" fw={700} c="teal.7">Certificates Generated for {selected.newOwnerName}</Text>
</Group>
{(selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS).map((cert) => (
<Text key={cert} fz="xs" c="teal.7"> {cert}</Text>
))}
</Paper>
)}
{!isTerminal && (
<Button color="violet" fullWidth onClick={handleOpenModal}>
Update Status
</Button>
)}
<Button variant="subtle" fullWidth onClick={() => navigate(`/vessel-ownership-transfer/${selected.id}`)}>
View Full Details
</Button>
</Stack>
)}
</Drawer>
{/* Status modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
<Stack gap="md">
<Select
label="New Status"
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
value={newStatus}
onChange={(v) => setNewStatus(v ?? '')}
/>
{newStatus === 'Approved' && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-teal-light)">
<Group gap="xs">
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7">
Approving will transfer ownership to <strong>{selected?.newOwnerName}</strong> and auto-generate{' '}
{selected?.category === 'Inland Waterway Vessel' ? '1 certificate' : '4 certificates'}.
</Text>
</Group>
</Paper>
)}
<Textarea label="Remarks" placeholder="Add any remarks or notes..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'} loading={saving} disabled={!newStatus} onClick={handleAction}>
{newStatus === 'Approved' ? 'Approve & Transfer' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Ownership transfer queue"
description="Vessel ownership transfer is not connected to the backend yet."
/>
</Container>
);
}
export default VesselOwnershipTransferQueuePage;

View File

@@ -1,334 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconCircleCheck,
IconDownload,
IconFileDescription,
IconShieldCheck,
IconTransferIn,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_TRANSFER_REQUESTS } from './VesselOwnershipTransferQueuePage';
import type { OwnershipTransferRequest, TransferStatus } from './VesselOwnershipTransferQueuePage';
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
const SEAGOING_CERTS = [
'Certificate of Nationality',
'Certificate of Ownership',
'Certificate of Registration',
'Minimum Safe Manning Certificate',
];
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
return (
<div>
<Text fz="xs" c="dimmed">{label}</Text>
<Text fz="sm" fw={500}>{value ?? '—'}</Text>
</div>
);
}
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselOwnershipTransferReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [record, setRecord] = useState<OwnershipTransferRequest | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [newStatus, setNewStatus] = useState('');
const [remarks, setRemarks] = useState('');
const [saving, setSaving] = useState(false);
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest>();
const [actionTrigger] = useApiMutation<{ success: boolean }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/vessel-ownership-transfers/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setRecord(data); setLoading(false); })
.catch(() => {
setRecord(MOCK_TRANSFER_REQUESTS.find((r) => r.id === id) ?? null);
setLoading(false);
});
}, [fetchTrigger, id]);
const handleOpenModal = () => {
if (!record) return;
setNewStatus(record.status);
setRemarks(record.remarks ?? '');
setModalOpen(true);
};
const handleAction = async () => {
if (!record) return;
setSaving(true);
const isApproval = newStatus === 'Approved';
try {
await actionTrigger({
url: `/vessel-ownership-transfers/${record.id}/status`,
method: 'PATCH',
body: { status: newStatus, remarks, generateCertificates: isApproval },
}).unwrap();
} catch { /* mock */ }
const today = new Date().toISOString().split('T')[0];
setRecord((prev) =>
prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : prev
);
setModalOpen(false);
setSaving(false);
if (isApproval) {
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
notify.success(`Transfer approved. ${certs.length} certificate(s) generated for ${record.newOwnerName}.`);
} else {
notify.success('Status updated.');
}
};
if (loading) return <Text fz="sm" c="dimmed" p="xl">Loading...</Text>;
if (!record) return (
<Stack p="xl" align="center">
<Text c="dimmed">Transfer request not found.</Text>
<Button variant="subtle" onClick={() => navigate('/vessel-ownership-transfer')}>Back to Queue</Button>
</Stack>
);
const isTerminal = record.status === 'Approved' || record.status === 'Rejected';
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer Review</Title>
<Text fz="sm" c="dimmed">{record.id} · {record.vesselName}</Text>
</div>
</Group>
<Group gap="sm">
<Badge size="lg" color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
<Button leftSection={<IconArrowLeft size={15} />} variant="default" size="sm" onClick={() => navigate('/vessel-ownership-transfer')}>
Back to Queue
</Button>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" style={{ alignItems: 'start' }}>
{/* Left column */}
<Stack gap="md">
{/* Current owner */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Current Owner</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Full Name" value={record.currentOwnerName} />
<InfoRow label="National ID / TIN" value={record.currentOwnerIdOrTin} />
<InfoRow label="Phone" value={record.currentOwnerPhone} />
</SimpleGrid>
</Paper>
{/* New owner */}
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-violet-3)' }}>
<Text fw={700} fz="sm" mb="sm" c="violet.7">New Owner</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Full Name" value={record.newOwnerName} />
<InfoRow label="National ID / TIN" value={record.newOwnerIdOrTin} />
<InfoRow label="Phone" value={record.newOwnerPhone} />
<InfoRow label="Email" value={record.newOwnerEmail} />
<InfoRow label="Address" value={record.newOwnerAddress} />
</SimpleGrid>
</Paper>
{/* Transfer info */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Transfer Details</Text>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs">
<InfoRow label="Vessel Name" value={record.vesselName} />
<InfoRow label="Vessel Category" value={record.category} />
<InfoRow label="Vessel Type" value={record.vesselType} />
<InfoRow label="Transfer Reason" value={record.transferReason} />
<InfoRow label="Submitted Date" value={record.submittedDate} />
<InfoRow label="Approval Date" value={record.approvalDate ?? 'Not yet approved'} />
</SimpleGrid>
</Paper>
</Stack>
{/* Right column */}
<Stack gap="md">
{/* Document */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Supporting Documents</Text>
<Divider mb="sm" />
<Card withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<ThemeIcon size={34} radius="sm" color="violet" variant="light">
<IconFileDescription size={18} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>Bill of Sale / Transfer Document</Text>
<Text fz="xs" c="dimmed">Legal transfer document</Text>
</div>
</Group>
<Group gap={6}>
<Button size="xs" variant="light" color="blue">View</Button>
<Button size="xs" variant="subtle" leftSection={<IconDownload size={13} />}>Download</Button>
</Group>
</Group>
</Card>
</Paper>
{/* Remarks */}
{record.remarks && (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks / Notes</Text>
<Divider mb="sm" />
<Text fz="sm">{record.remarks}</Text>
</Paper>
)}
{/* Timeline */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Timeline</Text>
<Divider mb="sm" />
<Stack gap="xs">
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="blue" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Submitted {record.submittedDate}</Text>
</Group>
{record.status !== 'Pending' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="yellow" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Under Review</Text>
</Group>
)}
{record.status === 'Approved' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="teal" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Approved {record.approvalDate}</Text>
</Group>
)}
{record.status === 'Rejected' && (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="red" variant="light">
<IconCircleCheck size={13} />
</ThemeIcon>
<Text fz="sm">Rejected</Text>
</Group>
)}
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{/* Certificates — shown after approval */}
{record.status === 'Approved' && (
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
<Group gap="xs" mb="sm">
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="sm" c="teal.7">Certificates Generated for {record.newOwnerName}</Text>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={{ base: 1, sm: 2, md: certs.length }} spacing="sm">
{certs.map((cert, i) => (
<Card key={cert} withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
<Group gap="sm" mb="xs">
<ThemeIcon size={28} radius="md" color="teal" variant="light">
<Text fz="xs" fw={800}>{i + 1}</Text>
</ThemeIcon>
<Text fz="sm" fw={600} style={{ flex: 1 }}>{cert}</Text>
</Group>
<Text fz="xs" c="dimmed" mb="xs">Issued to: {record.newOwnerName} · {record.approvalDate}</Text>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={13} />} fullWidth>
Download Certificate
</Button>
</Card>
))}
</SimpleGrid>
</Paper>
)}
{/* Action bar */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fz="sm" c="dimmed">Current status: <strong>{record.status}</strong></Text>
<Button color="violet" onClick={handleOpenModal}>Update Status</Button>
</Group>
</Paper>
)}
{/* Status modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
<Stack gap="md">
<Select
label="New Status"
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
value={newStatus}
onChange={(v) => setNewStatus(v ?? '')}
/>
{newStatus === 'Approved' && (
<Alert icon={<IconCircleCheck size={14} />} color="teal" variant="light">
Approving will officially transfer ownership to <strong>{record.newOwnerName}</strong> and generate{' '}
{certs.length} certificate{certs.length > 1 ? 's' : ''}.
</Alert>
)}
<Textarea label="Remarks" placeholder="Add remarks..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'}
loading={saving}
disabled={!newStatus}
onClick={handleAction}
>
{newStatus === 'Approved' ? 'Approve & Transfer Ownership' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Ownership transfer review"
description="Vessel ownership transfer is not connected to the backend yet."
/>
</Container>
);
}
export default VesselOwnershipTransferReviewPage;

View File

@@ -1,535 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShieldCheck,
IconX,
IconAlertCircle,
IconClockHour4,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
export type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
export type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
export interface VesselRegistration {
id: string;
vesselName: string;
category: VesselCategory;
vesselType: string;
flagState: string;
portOfRegistry: string;
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
capacityValue: number;
vesselLengthM: number;
imoOrHullNumber: string;
manufacturerShipyard: string;
yearBuilt: number;
engineType: string;
enginePowerKw: number;
numberOfEngines: number;
hullMaterial: string;
ownerName: string;
ownerNationalIdOrTin: string;
ownerPhone: string;
ownerAddress: string;
status: VesselRegStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
renewalStatus: RenewalStatus;
expiryDate: string | null;
docsComplete: boolean;
}
export const MOCK_VESSEL_REGISTRATIONS: VesselRegistration[] = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
flagState: 'Ethiopia',
portOfRegistry: 'Bahir Dar',
capacityLabel: 'Passenger Capacity',
capacityValue: 120,
vesselLengthM: 32,
imoOrHullNumber: 'ETH-INL-2024-0042',
manufacturerShipyard: 'Ethio Marine Works',
yearBuilt: 2019,
engineType: 'Diesel Engine',
enginePowerKw: 450,
numberOfEngines: 2,
hullMaterial: 'Steel',
ownerName: 'Abebe Girma',
ownerNationalIdOrTin: 'ET-9812345',
ownerPhone: '+251 911 234 567',
ownerAddress: 'Bahir Dar, Amhara Region',
status: 'Under Review',
submittedDate: '2024-03-15',
approvalDate: null,
remarks: 'Documents submitted. Under initial review by maritime officer.',
renewalStatus: 'Not Applicable',
expiryDate: null,
docsComplete: true,
},
{
id: 'VR-2024-002',
vesselName: 'Red Sea Voyager',
category: 'Sea-going Vessel (International)',
vesselType: 'General Cargo',
flagState: 'Ethiopia',
portOfRegistry: 'Djibouti (Nominated)',
capacityLabel: 'Gross Tonnage (GT)',
capacityValue: 4200,
vesselLengthM: 98,
imoOrHullNumber: 'IMO9876543',
manufacturerShipyard: 'Hyundai Heavy Industries',
yearBuilt: 2015,
engineType: 'Diesel Engine',
enginePowerKw: 8500,
numberOfEngines: 1,
hullMaterial: 'Steel',
ownerName: 'Ethio Shipping Lines PLC',
ownerNationalIdOrTin: 'TIN-0045678',
ownerPhone: '+251 115 501 010',
ownerAddress: 'Addis Ababa, Bole Sub-city',
status: 'Pending',
submittedDate: '2024-04-02',
approvalDate: null,
remarks: '',
renewalStatus: 'Not Applicable',
expiryDate: null,
docsComplete: false,
},
{
id: 'VR-2023-018',
vesselName: 'Hawassa Queen',
category: 'Inland Waterway Vessel',
vesselType: 'Water Taxi',
flagState: 'Ethiopia',
portOfRegistry: 'Hawassa',
capacityLabel: 'Passenger Capacity',
capacityValue: 24,
vesselLengthM: 12,
imoOrHullNumber: 'ETH-INL-2023-0018',
manufacturerShipyard: 'Ethio Marine Works',
yearBuilt: 2022,
engineType: 'Outboard Motor',
enginePowerKw: 90,
numberOfEngines: 2,
hullMaterial: 'Aluminum',
ownerName: 'Yohannes Desta',
ownerNationalIdOrTin: 'ET-4456789',
ownerPhone: '+251 933 112 234',
ownerAddress: 'Hawassa, Sidama Region',
status: 'Approved',
submittedDate: '2023-11-10',
approvalDate: '2023-12-05',
remarks: 'All documents verified. Registration approved.',
renewalStatus: 'Valid',
expiryDate: '2028-12-05',
docsComplete: true,
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
function computeRenewalStatus(approvalDate: string | null): RenewalStatus {
if (!approvalDate) return 'Not Applicable';
const expiry = new Date(approvalDate);
expiry.setFullYear(expiry.getFullYear() + 5);
const days = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
if (days < 0) return 'Overdue';
if (days <= 180) return 'Due Soon';
return 'Valid';
}
function RenewalBadge({ status }: { status: RenewalStatus }) {
if (status === 'Due Soon') return <Badge color="orange" size="xs">Due Soon</Badge>;
if (status === 'Overdue') return <Badge color="red" size="xs">Overdue</Badge>;
if (status === 'Valid') return <Badge color="teal" size="xs">Valid</Badge>;
return <Text fz="xs" c="dimmed"></Text>;
}
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function VesselDrawer({
reg,
opened,
onClose,
onAction,
onFullReview,
}: {
reg: VesselRegistration | 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 (!reg) return null;
const isTerminal = reg.status === 'Approved' || reg.status === 'Rejected';
const submit = (action: 'approve' | 'reject' | 'correction') => {
onAction(reg.id, action, remarks);
setRemarks('');
setConfirmModal(null);
onClose();
};
return (
<>
<Drawer opened={opened} onClose={onClose} title={`Registration ${reg.id}`} position="right" size="lg" padding="lg">
<Stack gap="md">
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(reg.id); }}>
Open Full Review Page
</Button>
{/* Vessel info */}
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Vessel Name', value: reg.vesselName },
{ label: 'Category', value: reg.category },
{ label: 'Type', value: reg.vesselType },
{ label: reg.capacityLabel, value: String(reg.capacityValue) },
{ label: 'Length (m)', value: String(reg.vesselLengthM) },
{ label: 'IMO / Hull No.', value: reg.imoOrHullNumber },
{ label: 'Owner', value: reg.ownerName },
{ label: 'Submitted', value: reg.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
{/* Document checklist */}
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Vessel Photos', ok: reg.docsComplete },
{ label: 'Proof of Ownership', ok: reg.docsComplete },
{ label: "Builder's Certificate", ok: reg.docsComplete },
{ label: 'Insurance Certificate', ok: reg.docsComplete },
{ label: 'Tax Clearance Certificate', ok: reg.docsComplete },
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Status */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light">{reg.status}</Badge>
</Group>
{reg.remarks && <Text fz="xs" c="dimmed">{reg.remarks}</Text>}
</Paper>
{/* Actions */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!reg.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('correction')}>
Request Correction
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Correction'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve registration ${reg.id} for vessel "${reg.vesselName}"?`
: confirmModal === 'reject'
? `Reject registration ${reg.id}? This cannot be undone.`
: `Request corrections for registration ${reg.id}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<VesselRegistration[]>(MOCK_VESSEL_REGISTRATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [selectedReg, setSelectedReg] = useState<VesselRegistration | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<VesselRegistration[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => {
setApps((prev) => prev.map((r) => {
if (r.id !== id) return r;
const newStatus: VesselRegStatus =
action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Correction Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : r.approvalDate;
return {
...r,
status: newStatus,
approvalDate,
remarks: remarks || r.remarks,
renewalStatus: action === 'approve' ? computeRenewalStatus(approvalDate) : r.renewalStatus,
expiryDate: action === 'approve' && approvalDate
? (() => { const d = new Date(approvalDate); d.setFullYear(d.getFullYear() + 5); return d.toISOString().split('T')[0]; })()
: r.expiryDate,
};
}));
notify.success(
action === 'approve' ? 'Registration approved.' :
action === 'reject' ? 'Registration rejected.' :
'Correction request sent.'
);
};
const filtered = apps.filter((r) => {
const q = search.toLowerCase();
const matchSearch = !q || r.vesselName.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || r.ownerName.toLowerCase().includes(q);
const matchStatus = !statusFilter || r.status === statusFilter;
const matchCat = !categoryFilter || r.category === categoryFilter;
return matchSearch && matchStatus && matchCat;
});
const stats = {
total: apps.length,
underReview: apps.filter((r) => r.status === 'Under Review').length,
approved: apps.filter((r) => r.status === 'Approved').length,
rejected: apps.filter((r) => r.status === 'Rejected').length,
};
const rows = filtered.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td>
<Text fz="sm" fw={500}>{reg.id}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.vesselName}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.vesselType}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'}>
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td>
<Text fz="sm">{reg.ownerName}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" c="dimmed">{reg.submittedDate}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
{reg.status}
</Badge>
</Table.Td>
<Table.Td>
<RenewalBadge status={reg.renewalStatus} />
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/vessel-registration-queue/${reg.id}`)}>
Review
</Button>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => { setSelectedReg(reg); setDrawerOpen(true); }}
>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration Queue</Title>
<Text fz="sm" c="dimmed">Review and process vessel registration applications</Text>
</div>
{/* Stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'Under Review', value: stats.underReview, color: 'yellow' },
{ label: 'Approved', value: stats.approved, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
{/* Filters */}
<Group gap="sm">
<TextInput
placeholder="Search by vessel name, ID, or owner..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
clearable
data={['Pending', 'Under Review', 'Approved', 'Rejected', 'Correction Required']}
value={statusFilter}
onChange={setStatusFilter}
w={200}
/>
<Select
placeholder="All categories"
clearable
data={['Inland Waterway Vessel', 'Sea-going Vessel (International)']}
value={categoryFilter}
onChange={setCategoryFilter}
w={220}
/>
</Group>
{/* Table */}
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Renewal</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={9}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No registrations found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<VesselDrawer
reg={selectedReg}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/vessel-registration-queue/${id}`)}
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration queue"
description="Vessel registration is not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export default VesselRegistrationQueuePage;

View File

@@ -1,235 +1,21 @@
import { useMemo } from 'react';
import {
Badge,
Card,
Group,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCircleCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration } from './VesselRegistrationQueuePage';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationReportPage() {
const data = MOCK_VESSEL_REGISTRATIONS;
const stats = useMemo(() => {
const total = data.length;
const inland = data.filter((r) => r.category === 'Inland Waterway Vessel').length;
const seagoing = data.filter((r) => r.category === 'Sea-going Vessel (International)').length;
const currentYear = new Date().getFullYear();
const approvedThisYear = data.filter(
(r) => r.status === 'Approved' && r.approvalDate?.startsWith(String(currentYear))
).length;
const statusCounts: Record<string, number> = {};
data.forEach((r) => { statusCounts[r.status] = (statusCounts[r.status] ?? 0) + 1; });
const statusDist = Object.entries(statusCounts).map(([label, count]) => ({
label,
count,
pct: total > 0 ? Math.round((count / total) * 100) : 0,
color: STATUS_COLOR[label] ?? 'gray',
}));
const typeCounts: Record<string, number> = {};
data.forEach((r) => { typeCounts[r.vesselType] = (typeCounts[r.vesselType] ?? 0) + 1; });
const typeDist = Object.entries(typeCounts)
.sort((a, b) => b[1] - a[1])
.map(([label, count]) => ({
label,
count,
pct: total > 0 ? Math.round((count / total) * 100) : 0,
}));
const renewalDue = data.filter(
(r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue'
);
const recent = [...data]
.sort((a, b) => new Date(b.submittedDate).getTime() - new Date(a.submittedDate).getTime())
.slice(0, 10);
return { total, inland, seagoing, approvedThisYear, statusDist, typeDist, renewalDue, recent };
}, [data]);
return (
<Stack gap="md">
<div>
<Title order={3}>Vessel Registration Report</Title>
<Text fz="sm" c="dimmed">Summary of all vessel registrations and renewal status</Text>
</div>
{/* KPI Cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Registered', value: stats.total, color: 'blue', icon: IconAnchor },
{ label: 'Inland Vessels', value: stats.inland, color: 'cyan', icon: IconWaveSine },
{ label: 'Sea-going Vessels', value: stats.seagoing, color: 'indigo', icon: IconShip },
{ label: 'Approved This Year', value: stats.approvedThisYear, color: 'teal', icon: IconCircleCheck },
].map((s) => {
const Icon = s.icon;
return (
<Card key={s.label} withBorder radius="md" p="md">
<Group gap="sm" mb={4}>
<ThemeIcon size={32} radius="md" color={s.color} variant="light">
<Icon size={18} />
</ThemeIcon>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
</Group>
<Text fz="2xl" fw={700} c={`${s.color}.6`}>{s.value}</Text>
</Card>
);
})}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Status Distribution */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Status Distribution</Text>
<Stack gap="sm">
{stats.statusDist.map((s) => (
<div key={s.label}>
<Group justify="space-between" mb={4}>
<Group gap="xs">
<Badge color={s.color} size="xs" variant="light">{s.label}</Badge>
</Group>
<Text fz="xs" c="dimmed">{s.count} ({s.pct}%)</Text>
</Group>
<Progress value={s.pct} color={s.color} size="sm" radius="xl" />
</div>
))}
</Stack>
</Paper>
{/* Vessel Type Breakdown */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Vessel Type Breakdown</Text>
<Stack gap="sm">
{stats.typeDist.map((t) => (
<div key={t.label}>
<Group justify="space-between" mb={4}>
<Text fz="sm">{t.label}</Text>
<Text fz="xs" c="dimmed">{t.count} ({t.pct}%)</Text>
</Group>
<Progress value={t.pct} color="blue" size="sm" radius="xl" />
</div>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Renewal Tracking */}
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconAlertCircle size={17} color="var(--mantine-color-orange-6)" />
<Text fw={700} fz="sm">Renewal Tracking</Text>
{stats.renewalDue.length > 0 && (
<Badge color="orange" size="sm">{stats.renewalDue.length} due</Badge>
)}
</Group>
{stats.renewalDue.length === 0 ? (
<Group gap="xs">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="sm" c="dimmed">No vessel registrations due for renewal in the next 180 days.</Text>
</Group>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Expiry Date</Table.Th>
<Table.Th>Renewal Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stats.renewalDue.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
<Table.Td>
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{reg.expiryDate ?? '—'}</Text></Table.Td>
<Table.Td>
<Badge color={reg.renewalStatus === 'Overdue' ? 'red' : 'orange'} size="sm">
{reg.renewalStatus}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
{/* Recent Registrations */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Recent Registrations (last {stats.recent.length})</Text>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Reg ID</Table.Th>
<Table.Th>Vessel Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stats.recent.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
<Table.Td>
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
</Badge>
</Table.Td>
<Table.Td><Text fz="sm">{reg.vesselType}</Text></Table.Td>
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{reg.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
{reg.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration report"
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
/>
</Container>
);
}
export default VesselRegistrationReportPage;

View File

@@ -1,393 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAnchor,
IconArrowLeft,
IconCamera,
IconCertificate,
IconCheck,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileDescription,
IconId,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration, VesselRegStatus } from './VesselRegistrationQueuePage';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Certificate definitions
// ---------------------------------------------------------------------------
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
const SEAGOING_CERTIFICATES = [
{ label: 'Certificate of Nationality', description: "Certifies the vessel's nationality and right to fly the Ethiopian flag" },
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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 computeExpiryDate(approvalDate: string): string {
const d = new Date(approvalDate);
d.setFullYear(d.getFullYear() + 5);
return d.toISOString().split('T')[0];
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
const [reg, setReg] = useState<VesselRegistration | null>(null);
const [status, setStatus] = useState<VesselRegStatus>('Pending');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/vessel-registrations/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setReg(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_VESSEL_REGISTRATIONS.find((r) => r.id === id) ?? null;
setReg(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Approved' || status === 'Rejected';
const handleAction = async () => {
if (!selectedStatus || !reg) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as VesselRegStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : reg.approvalDate;
setReg((prev) => prev ? {
...prev,
status: newStatus,
approvalDate,
remarks,
expiryDate: newStatus === 'Approved' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
} : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Registration ${newStatus.toLowerCase()}.`);
};
if (!reg) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Registration not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={180} onClick={() => navigate('/vessel-registration-queue')}>
Back to Queue
</Button>
</Stack>
);
}
const certs = reg.category === 'Sea-going Vessel (International)' ? SEAGOING_CERTIFICATES : INLAND_CERTIFICATES;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration-queue')}>
Back to Queue
</Button>
</Group>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconAnchor size={24} />
</ThemeIcon>
<div>
<Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} · {reg.category}</Text>
</div>
</Group>
{/* Terminal alerts */}
{status === 'Approved' && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Registration Approved">
This vessel registration was approved on {reg.approvalDate}. Expiry date: {reg.expiryDate}.
{reg.category === 'Sea-going Vessel (International)'
? ' Four certificates have been issued (Nationality, Ownership, Registration, Minimum Safe Manning).'
: ' Inland Vessel Registration Certificate has been issued.'}
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Registration Rejected">
This registration has been rejected. No further changes can be made.
</Alert>
)}
{/* Action bar */}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button
size="sm"
leftSection={<IconEdit size={16} />}
onClick={() => { setSelectedStatus(null); setModalOpen(true); }}
>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Left column */}
<Stack gap="md">
{/* Vessel Information */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Vessel Name" value={reg.vesselName} />
<InfoRow label="Category" value={reg.category} />
<InfoRow label="Vessel Type" value={reg.vesselType} />
<InfoRow label={reg.capacityLabel} value={String(reg.capacityValue)} />
<InfoRow label="Length (m)" value={String(reg.vesselLengthM)} />
<InfoRow label="Flag State" value={reg.flagState} />
<InfoRow label="Port of Registry" value={reg.portOfRegistry} />
</SimpleGrid>
</Paper>
{/* Technical Details */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="IMO / Hull Number" value={reg.imoOrHullNumber} />
<InfoRow label="Manufacturer / Shipyard" value={reg.manufacturerShipyard} />
<InfoRow label="Year Built" value={String(reg.yearBuilt)} />
<InfoRow label="Engine Type" value={reg.engineType} />
<InfoRow label="Engine Power (kW)" value={String(reg.enginePowerKw)} />
<InfoRow label="Number of Engines" value={String(reg.numberOfEngines)} />
<InfoRow label="Hull Material" value={reg.hullMaterial} />
</SimpleGrid>
</Paper>
{/* Ownership */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Ownership</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Owner Name" value={reg.ownerName} />
<InfoRow label="National ID / TIN" value={reg.ownerNationalIdOrTin} />
<InfoRow label="Phone" value={reg.ownerPhone} />
<InfoRow label="Address" value={reg.ownerAddress} />
</SimpleGrid>
</Paper>
</Stack>
{/* Right column */}
<Stack gap="md">
{/* Uploaded Documents */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{(reg.category === 'Inland Waterway Vessel'
? [
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
]
: [
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', fileName: 'proof_of_ownership.pdf', icon: IconFileDescription, ok: reg.docsComplete, required: true },
{ key: 'shipParticulars', label: 'Ship Particulars', fileName: 'ship_particulars.pdf', icon: IconId, ok: reg.docsComplete, required: true },
{ key: 'insuranceCertificate', label: 'Insurance Certificate', fileName: 'insurance_cert.pdf', icon: IconShieldCheck, ok: reg.docsComplete, required: true },
]
).map((doc) => {
const DocIcon = doc.icon;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={doc.ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}
{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{doc.ok ? (
<Text fz="xs" c="dimmed">{doc.fileName}</Text>
) : (
<Text fz="xs" c="red">Not uploaded</Text>
)}
</div>
</Group>
{doc.ok && (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
)}
{!doc.ok && (
<ThemeIcon size={22} radius="xl" color="red" variant="light">
<IconX size={13} />
</ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
{/* Remarks */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={reg.remarks ? undefined : 'dimmed'}>{reg.remarks || 'No remarks.'}</Text>
</Paper>
{/* Submission info */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={reg.submittedDate} />
<InfoRow label="Approval Date" value={reg.approvalDate ?? 'Pending'} />
<InfoRow label="Expiry Date (5 years)" value={reg.expiryDate ?? 'Not yet set'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{/* ── Issued Certificates — full width, shown after approval ── */}
{status === 'Approved' && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">
{reg.category === 'Sea-going Vessel (International)'
? 'Issued Certificates — Sea-going Vessel (4 Certificates)'
: 'Issued Certificate — Inland Vessel'}
</Text>
<Text fz="xs" c="dimmed">
Approved on {reg.approvalDate} · Valid until {reg.expiryDate} (5 years)
</Text>
</div>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{certs.map((cert, idx) => (
<Card key={cert.label} withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled">
<IconCertificate size={16} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{cert.label}</Text>
<Text fz="xs" c="dimmed">{cert.description}</Text>
</div>
</Group>
<Badge color="teal" size="xs" variant="filled">#{idx + 1}</Badge>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>
Preview
</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>
Download
</Button>
</Group>
</Card>
))}
</SimpleGrid>
</Paper>
)}
{/* Status update modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Registration Status" size="md">
<Stack gap="md">
<Select
label="New Status"
placeholder="Select status"
data={['Under Review', 'Approved', 'Rejected', 'Correction Required']}
value={selectedStatus}
onChange={setSelectedStatus}
/>
<Textarea
label="Remarks"
placeholder="Add notes for the vessel owner..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration review"
description="Vessel registration is not connected to the backend yet."
/>
</Container>
);
}
export default VesselRegistrationReviewPage;

View File

@@ -1,452 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShieldOff,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type WaiverType = 'Pre-Waiver' | 'Post-Waiver';
export type WaiverStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Penalty Payment Pending'
| 'Payment Confirmed'
| 'Letter Generated'
| 'Completed';
export interface WaiverApplication {
id: string;
companyName: string;
tinNumber: string;
importCertNumber: string;
invoiceNumber: string;
billOfLadingNumber: string;
vesselName: string;
portOfLoading: string;
portOfDischarge: string;
waiverType: WaiverType;
status: WaiverStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
docsComplete: boolean;
penaltyAmount: number | null;
penaltyPaid: boolean;
}
export const MOCK_WAIVER_APPLICATIONS: WaiverApplication[] = [
{
id: 'WVR-2024-001',
companyName: 'Blue Nile Import & Trading PLC',
tinNumber: 'TIN-0098231',
importCertNumber: 'IC-772341',
invoiceNumber: 'INV-55231',
billOfLadingNumber: 'BL-90211',
vesselName: 'MV Horizon Star',
portOfLoading: 'Jebel Ali',
portOfDischarge: 'Djibouti',
waiverType: 'Pre-Waiver',
status: 'Under Evaluation',
submittedDate: '2024-03-14',
approvalDate: null,
remarks: 'Reviewing shipment schedule evidence.',
docsComplete: true,
penaltyAmount: null,
penaltyPaid: false,
},
{
id: 'WVR-2024-002',
companyName: 'Red Sea Gateway Logistics Ltd',
tinNumber: 'TIN-0071122',
importCertNumber: 'IC-813457',
invoiceNumber: 'INV-61120',
billOfLadingNumber: 'BL-77102',
vesselName: 'MV Amber Wave',
portOfLoading: 'Salalah',
portOfDischarge: 'Djibouti',
waiverType: 'Post-Waiver',
status: 'Penalty Payment Pending',
submittedDate: '2024-04-05',
approvalDate: '2024-04-10',
remarks: 'Approved. Awaiting penalty payment.',
docsComplete: true,
penaltyAmount: 15000,
penaltyPaid: false,
},
{
id: 'WVR-2023-017',
companyName: 'Tana Maritime & Trading PLC',
tinNumber: 'TIN-0045690',
importCertNumber: 'IC-704128',
invoiceNumber: 'INV-40213',
billOfLadingNumber: 'BL-33012',
vesselName: 'MV Nile Pioneer',
portOfLoading: 'Port Sudan',
portOfDischarge: 'Djibouti',
waiverType: 'Post-Waiver',
status: 'Completed',
submittedDate: '2023-10-12',
approvalDate: '2023-11-08',
remarks: 'Penalty paid. Letter generated and downloaded.',
docsComplete: true,
penaltyAmount: 12000,
penaltyPaid: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Penalty Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Letter Generated': 'green',
Completed: 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: WaiverApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Completed';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
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>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Applicant Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Import Certificate No.', value: app.importCertNumber },
{ label: 'Waiver Type', value: app.waiverType },
{ label: 'Vessel Name', value: app.vesselName },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Import Certificate', ok: app.docsComplete },
{ label: 'Invoice for Imported Products', ok: app.docsComplete },
{ label: 'TIN Certificate', ok: app.docsComplete },
{ label: 'Applicant Declaration', ok: app.docsComplete },
...(app.waiverType === 'Post-Waiver'
? [{ label: 'Bill of Lading & Arrival Notice', ok: app.docsComplete }]
: []),
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function WaiverQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<WaiverApplication[]>(MOCK_WAIVER_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<WaiverApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<WaiverApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-waivers?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: WaiverStatus =
action === 'approve'
? (a.waiverType === 'Post-Waiver' ? 'Penalty Payment Pending' : 'Letter Generated')
: action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
const matchType = !typeFilter || a.waiverType === typeFilter;
return matchSearch && matchStatus && matchType;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
letterGenerated: apps.filter((a) => a.status === 'Letter Generated' || a.status === 'Completed').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Badge variant="outline" size="sm" color={app.waiverType === 'Post-Waiver' ? 'grape' : 'blue'}>{app.waiverType}</Badge></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/waiver/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Maritime Logistics Waiver Queue</Title>
<Text fz="sm" c="dimmed">Review and process Pre-Waiver and Post-Waiver applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Letters Generated', value: stats.letterGenerated, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All types"
clearable
data={['Pre-Waiver', 'Post-Waiver']}
value={typeFilter}
onChange={setTypeFilter}
w={160}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Waiver Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/waiver/${id}`)}
<Container size="lg" py="xl">
<FeatureUnavailable
title="Waiver queue"
description="Waiver applications are not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export const WAIVER_ICON = IconShieldOff;
export default WaiverQueuePage;

View File

@@ -1,361 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconAlertTriangle,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileCertificate,
IconFileDescription,
IconId,
IconReceipt,
IconShieldOff,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_WAIVER_APPLICATIONS, STATUS_COLOR } from './WaiverQueuePage';
import type { WaiverApplication, WaiverStatus } from './WaiverQueuePage';
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>
);
}
const DOCS_COMMON = [
{ key: 'importCertificate', label: 'Import Certificate', fileName: 'import_certificate.pdf', icon: IconId, required: true },
{ key: 'invoice', label: 'Invoice for Imported Products', fileName: 'invoice.pdf', icon: IconFileDescription, required: true },
{ key: 'tinCert', label: 'TIN Certificate', fileName: 'tin_certificate.pdf', icon: IconId, required: true },
{ key: 'declaration', label: 'Applicant Declaration', fileName: 'declaration.pdf', icon: IconFileDescription, required: true },
];
const DOCS_POST_WAIVER = [
{ key: 'billOfLading', label: 'Bill of Lading', fileName: 'bill_of_lading.pdf', icon: IconFileDescription, required: true },
{ key: 'arrivalNotice', label: 'Arrival Notice / Port Arrival Evidence', fileName: 'arrival_notice.pdf', icon: IconFileDescription, required: true },
{ key: 'penaltyReceipt', label: 'Penalty Payment Receipt', fileName: 'penalty_receipt.pdf', icon: IconReceipt, required: false },
];
// Duplicate Post-Waiver check fields per BR-WVR-006/007 — same import/cargo case identifiers.
function findDuplicatePostWaiver(app: WaiverApplication, all: WaiverApplication[]): WaiverApplication | null {
if (app.waiverType !== 'Post-Waiver') return null;
return all.find((other) =>
other.id !== app.id &&
other.waiverType === 'Post-Waiver' &&
(other.status === 'Approved' || other.status === 'Letter Generated' || other.status === 'Completed') &&
other.tinNumber === app.tinNumber &&
other.importCertNumber === app.importCertNumber &&
other.invoiceNumber === app.invoiceNumber &&
other.billOfLadingNumber === app.billOfLadingNumber &&
other.vesselName === app.vesselName &&
other.portOfLoading === app.portOfLoading &&
other.portOfDischarge === app.portOfDischarge
) ?? null;
}
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function WaiverReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<WaiverApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<WaiverApplication | null>(null);
const [status, setStatus] = useState<WaiverStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-waivers/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_WAIVER_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Completed';
const isPostWaiver = app?.waiverType === 'Post-Waiver';
const duplicate = app ? findDuplicatePostWaiver(app, MOCK_WAIVER_APPLICATIONS) : null;
// Statuses assignable via the modal, gated by BR-WVR-014/015: no letter generation
// before approval, and no Post-Waiver letter before penalty payment confirmation.
const availableStatuses = (() => {
const base = ['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected'];
if (!isPostWaiver) return [...base, 'Letter Generated', 'Completed'];
return [...base, 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed'];
})();
const canSelectLetterGenerated = (s: string) => {
if (s !== 'Letter Generated') return true;
if (!isPostWaiver) return status === 'Approved' || status === 'Letter Generated';
return status === 'Payment Confirmed' || status === 'Letter Generated';
};
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as WaiverStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? { ...prev, status: newStatus, approvalDate, remarks } : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/waiver')}>
Back to Queue
</Button>
</Stack>
);
}
const docs = [...DOCS_COMMON, ...(isPostWaiver ? DOCS_POST_WAIVER : [])];
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/waiver')}>
Back to Queue
</Button>
<Group gap="xs">
<Badge variant="outline" size="lg" color={isPostWaiver ? 'grape' : 'blue'}>{app.waiverType}</Badge>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShieldOff size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Maritime Logistics Waiver</Text>
</div>
</Group>
{duplicate && status !== 'Rejected' && (
<Alert icon={<IconAlertTriangle size={17} />} color="red" title="Duplicate Post-Waiver Detected">
An already {duplicate.status === 'Letter Generated' || duplicate.status === 'Completed' ? 'letter-generated' : 'approved'} Post-Waiver
application ({duplicate.id}) exists for the same TIN, import certificate, invoice, bill of lading, vessel, and port combination.
Post-Waiver is issued only one time per import/cargo case do not approve this application.
</Alert>
)}
{(status === 'Letter Generated' || status === 'Completed') && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Waiver Letter Generated">
Letter generated on {app.approvalDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{isPostWaiver && status === 'Penalty Payment Pending' && (
<Alert icon={<IconAlertTriangle size={17} />} color="grape" title="Penalty Payment Pending">
Applicant must pay the penalty ({app.penaltyAmount?.toLocaleString() ?? '—'} ETB) before the waiver letter can be generated.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Applicant Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Import Certificate No." value={app.importCertNumber} />
<InfoRow label="Waiver Type" value={app.waiverType} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Shipment & Vessel</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Invoice Number" value={app.invoiceNumber} />
<InfoRow label="Bill of Lading No." value={app.billOfLadingNumber} />
<InfoRow label="Vessel Name" value={app.vesselName} />
<InfoRow label="Port of Loading" value={app.portOfLoading} />
<InfoRow label="Port of Discharge" value={app.portOfDischarge} />
</SimpleGrid>
</Paper>
{isPostWaiver && (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Penalty Payment</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Penalty Amount" value={app.penaltyAmount ? `${app.penaltyAmount.toLocaleString()} ETB` : 'Not yet assessed'} />
<InfoRow label="Payment Status" value={app.penaltyPaid ? 'Paid' : 'Not Paid'} />
</SimpleGrid>
</Paper>
)}
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{docs.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{(status === 'Letter Generated' || status === 'Completed') && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconFileCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">EMA Waiver Letter {app.waiverType}</Text>
<Text fz="xs" c="dimmed">Generated on {app.approvalDate}</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconFileCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Official EMA Waiver Letter (Bank Copy)</Text>
<Text fz="xs" c="dimmed">Addressed to bank for import clearance</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
{duplicate && (
<Alert icon={<IconAlertTriangle size={16} />} color="red">
Duplicate Post-Waiver case ({duplicate.id}) already exists approval is not recommended.
</Alert>
)}
<Select
label="New Status"
placeholder="Select status"
data={availableStatuses.map((s) => ({ value: s, label: s, disabled: !canSelectLetterGenerated(s) }))}
value={selectedStatus}
onChange={setSelectedStatus}
/>
{selectedStatus === 'Letter Generated' && !canSelectLetterGenerated(selectedStatus) && (
<Alert color="orange" fz="xs">
{isPostWaiver
? 'Letter can only be generated after penalty payment is confirmed.'
: 'Letter can only be generated after approval.'}
</Alert>
)}
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Letter Generated' || selectedStatus === 'Completed' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus || !canSelectLetterGenerated(selectedStatus)}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Waiver review"
description="Waiver applications are not connected to the backend yet."
/>
</Container>
);
}
export default WaiverReviewPage;

View File

@@ -26,6 +26,30 @@ export const am: Translations = {
},
nav: {
groupLicensing: 'ፈቃድ አሰጣጥ',
allApplications: 'ሁሉም ማመልከቻዎች',
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
byType: 'በዓይነት',
typeFreightForwarder: 'የጭነት አስተላላፊ',
typeShippingAgent: 'የመርከብ ወኪል',
typeCombined: 'ጥምር የመርከብ ወኪል እና ጭነት አስተላላፊ',
typeJointInvestment: 'የጋራ ኢንቨስትመንት',
typeMto: 'የብዝሃ-ሁነታ ትራንስፖርት አንቀሳቃሽ',
primary: 'ዋና',
destinations: 'ወደ',
noResults: 'ምንም አልተገኘም',
commandPlaceholder: 'ማያ ገጾችን፣ ማመልከቻዎችን፣ ኩባንያዎችን፣ ቲን ይፈልጉ…',
pending: 'በመጠባበቅ ላይ ያሉ',
pendingCount_one: '{{count}} በመጠባበቅ ላይ',
pendingCount_other: '{{count}} በመጠባበቅ ላይ',
groupSeafarer: 'የመርከበኞች አገልግሎት',
groupVessels: 'መርከቦች',
groupExaminations: 'ፈተናዎች',
groupAdministration: 'አስተዳደር',
groupAccount: 'መለያ',
soon: 'በቅርቡ',
details: 'ዝርዝር',
licenceReview: 'የፈቃድ ማመልከቻዎች',
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
@@ -612,4 +636,293 @@ export const am: Translations = {
departmentRequired: 'ክፍል ያስፈልጋል',
},
},
queue: {
title: 'የፈቃድ ማመልከቻዎች',
search: 'ፍለጋ',
searchPlaceholder: 'ኩባንያ፣ ቲን ወይም ቁጥር',
status: 'ሁኔታ',
anyStatus: 'ማንኛውም',
type: 'የፈቃድ ዓይነት',
anyType: 'ማንኛውም',
typeCol: 'ዓይነት',
statusCol: 'ሁኔታ',
submittedFrom: 'ከቀን ጀምሮ የቀረበ',
submittedTo: 'እስከ ቀን የቀረበ',
clearFilters: 'አጽዳ',
refresh: 'አድስ',
export: 'ወደ CSV ላክ',
exportTruncated: 'ወደ ውጭ መላክ ተቆርጧል',
exportTruncatedBody: 'ከ{{total}} ረድፎች ውስጥ የመጀመሪያዎቹ {{exported}} ተልከዋል። ለቀሪው ማጣሪያውን ያጥቡ።',
exportFailed: 'ወደ ውጭ መላክ አልተሳካም',
comfortable: 'ሰፊ',
compact: 'ጥብቅ',
number: 'ማመልከቻ ቁ.',
company: 'ኩባንያ',
tin: 'ቲን',
submitted: 'የቀረበበት',
sla: 'ዕድሜ / የጊዜ ገደብ',
claim: 'ውሰድ',
review: 'ገምግም',
claimed: 'ተወስዷል',
claimedBody: 'ማመልከቻው አሁን ለእርስዎ ተመድቧል።',
claimFailed: 'መውሰድ አልተቻለም',
claimRace: 'ሌላ ሹም አስቀድሞ ወስዶታል።',
bulkClaim_one: '{{count}} ውሰድ',
bulkClaim_other: '{{count}} ውሰድ',
bulkClaimed_one: '{{count}} ተወስዷል',
bulkClaimed_other: '{{count}} ተወስደዋል',
bulkClaimPartial_one: '{{count}} አስቀድሞ በሌላ ሹም ተወስዷል።',
bulkClaimPartial_other: '{{count}} አስቀድሞ በሌሎች ሹማምንት ተወስደዋል።',
selectAll: 'ሁሉንም ምረጥ',
selectRow: '{{number}} ምረጥ',
selectedCount_one: '{{count}} ተመርጧል',
selectedCount_other: '{{count}} ተመርጠዋል',
showing: 'ከ{{total}} ውስጥ {{from}}{{to}} በማሳየት ላይ',
empty: 'እዚህ የሚጠብቅ ነገር የለም',
emptyBody: 'አዲስ ማመልከቻዎች ሲቀርቡ እዚህ ይታያሉ።',
emptyFiltered: 'በእነዚህ ማጣሪያዎች የሚዛመድ ማመልከቻ የለም',
emptyFilteredBody: 'ማጣሪያዎቹን ለማስፋት ወይም ለማጽዳት ይሞክሩ።',
errorTitle: 'ወረፋውን መጫን አልተቻለም',
views: {
unassigned: 'ያልተመደበ',
mine: 'የእኔ ወረፋ',
awaitingApplicant: 'አመልካችን በመጠባበቅ',
overdue: 'ጊዜው ያለፈበት',
readyToIssue: 'ለመስጠት ዝግጁ',
all: 'ሁሉም',
},
},
review: {
summary: 'ማጠቃለያ',
officer: 'ሹም',
supervisor: 'የበላይ ኃላፊ',
officerPlaceholder: 'ማን እንደሚረከበው ይምረጡ',
noOfficers: 'ምንም ሹም አልተገኘም',
typeToConfirm: 'ለማረጋገጥ {{number}} ይተይቡ',
confirmMismatch: 'አይዛመድም',
type: 'ዓይነት',
tin: 'ቲን',
kind: 'ዓይነት',
submitted: 'የቀረበበት',
slaLabel: 'የጊዜ ገደብ',
eligibility: 'ብቁነት',
statusTimeline: 'ሂደት',
assigned: 'ተመድቧል',
decisionBar: 'የውሳኔ አሞሌ',
moreActions: 'ተጨማሪ ተግባራት',
irreversible: 'መመለስ አይቻልም',
irreversibleWarning: 'ይህ ውሳኔ የመጨረሻ ሲሆን ከጀርባ ቢሮ መመለስ አይቻልም።',
irreversibleAck: 'ይህ የመጨረሻ መሆኑን ተረድቻለሁ',
reasonCode: 'ምክንያት',
reasonCodePlaceholder: 'ምክንያት ይምረጡ',
reasonDetail: 'ለአመልካቹ ዝርዝር',
reasonDetailHint: 'ይህ ጽሑፍ ለአመልካቹ እንዳለ ይላካል።',
deficiencies: 'አመልካቹ ማስተካከል ያለበት ነገሮች',
deficienciesHint: 'የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።',
notificationPreview: 'ለአመልካቹ የሚላክ መልእክት',
notificationPreviewHint: 'በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።',
needsCorrection: 'ማስተካከያ ያስፈልገዋል',
correctionPlaceholder: 'አመልካቹ ምን ማስተካከል አለበት?',
verifiedCapital: 'የተረጋገጠ ካፒታል (ብር)',
capitalHint: 'ዝቅተኛ {{min}} — ከባንክ ደብዳቤ ጋር ያረጋግጡ',
capitalHintNoMin: 'ከባንክ ደብዳቤ ጋር ተረጋግጧል',
capitalLocked: 'በዚህ ደረጃ ካፒታል ማስተካከል አይቻልም።',
belowMinimum: 'ከ{{min}} ዝቅተኛ በታች',
declared: 'አመልካቹ ያሳወቀው',
role: 'ሚና',
name: 'ስም',
evidence: 'ማስረጃ',
noInspections: 'እስካሁን ምርመራ አልተያዘም።',
unscheduled: 'አልተያዘም',
inspectionResult: 'የምርመራ ውጤት',
findings: 'ግኝቶች',
dateTime: 'ቀን እና ሰዓት',
schedule: 'ያዝ',
pickDate: 'መጀመሪያ ቀን እና ሰዓት ይምረጡ',
passed: 'አልፏል',
failed: 'ወድቋል',
round_one: 'ዙር {{count}}',
round_other: 'ዙር {{count}}',
theApplicant: 'አመልካቹ',
linkCopied: 'አገናኝ ተቀድቷል',
actionFailed: 'ተግባሩ አልተሳካም',
errorTitle: 'ይህን ማመልከቻ መጫን አልተቻለም',
hideActivity: 'እንቅስቃሴ ደብቅ',
showActivity: 'እንቅስቃሴ አሳይ',
awaitingPayment: 'አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።',
tabs: {
overview: 'አጠቃላይ እይታ',
financials: 'የገንዘብ መረጃ',
documents: 'ሰነዶች',
staff: 'ሠራተኞች',
inspection: 'ምርመራ',
},
actions: {
claim: 'ውሰድ',
assign: 'መድብ',
escalate: 'ወደ ላይ አሳድግ',
hold: 'አግድ',
resume: 'ቀጥል',
completeReview: 'ግምገማ አጠናቅቅ',
approveDocuments: 'ሰነዶችን አጽድቅ',
scheduleInspection: 'ምርመራ ያዝ',
recordInspection: 'የምርመራ ውጤት መዝግብ',
finalApprove: 'አጽድቅ እና ስጥ',
requestAdjustment: 'ማስተካከያ ጠይቅ',
reject: 'አትቀበል',
confirmPayment: 'ክፍያ አረጋግጥ',
print: 'ሰነድ አትም',
copyLink: 'አገናኝ ቅዳ',
downloadDocuments: 'ሁሉንም ሰነዶች አውርድ',
generateCertificate: 'ሰርተፍኬት አዘጋጅ',
auditTrail: 'የኦዲት መዝገብ አሳይ',
},
disabled: {
wrongStatus: 'በዚህ ደረጃ አይገኝም',
notAssigned: 'ለሌላ ሹም ተመድቧል',
noPermission: 'ፈቃድ የለዎትም',
needsFlags: 'ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ',
needsCapital: 'መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ',
needsInspection: 'የምርመራ ውጤት ያስፈልጋል',
},
reasons: {
incompleteDocuments: 'ያልተሟሉ ሰነዶች',
belowCapital: 'ካፒታል ከሚያስፈልገው በታች',
failedInspection: 'ምርመራ ወድቋል',
ineligibleApplicant: 'አመልካቹ ብቁ አይደለም',
duplicateApplication: 'ተደጋጋሚ ማመልከቻ',
illegibleDocument: 'ሰነዱ አይነበብም',
expiredDocument: 'ሰነዱ ጊዜው አልፎበታል',
missingDocument: 'ሰነዱ ጠፍቷል',
inconsistentDetails: 'ዝርዝሮቹ ከሰነዶቹ ጋር አይዛመዱም',
awaitingThirdParty: 'የሶስተኛ ወገን ማረጋገጫ በመጠባበቅ',
legalProceedings: 'በሕግ ሂደት ላይ',
applicantRequest: 'በአመልካቹ ጥያቄ',
aboveAuthority: 'ከእኔ የማጽደቅ ሥልጣን በላይ',
policyUnclear: 'የፖሊሲ መመሪያ ያስፈልጋል',
conflictOfInterest: 'የጥቅም ግጭት',
},
consequences: {
fallback: 'ይህ ለ{{applicant}} ማመልከቻ {{number}} ያዘምናል።',
'final-approve': 'ለ{{applicant}} ማመልከቻ {{number}} ያጸድቃል እና የሰርተፍኬት አሰጣጥ ይጀምራል።',
reject: 'ለ{{applicant}} ማመልከቻ {{number}} አይቀበልም። ይህ ማመልከቻውን ያጠናቅቃል።',
'request-adjustment': 'ማመልከቻ {{number}} ለማስተካከያ ወደ {{applicant}} ይመልሳል።',
hold: 'ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።',
resume: 'ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።',
escalate: 'ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።',
'confirm-payment': 'ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።',
},
notifications: {
fallback: 'ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።',
'final-approve': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ጸድቋል። ሰርተፍኬትዎ በዝግጅት ላይ ነው።',
reject: 'ውድ {{applicant}}፣ ማመልከቻ {{number}} አልጸደቀም። እባክዎ ከታች ያለውን ምክንያት ይመልከቱ።',
'request-adjustment': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ከመቀጠሉ በፊት ማስተካከያ ያስፈልገዋል።',
},
activity: {
title: 'እንቅስቃሴ እና የኦዲት መዝገብ',
empty: 'እስካሁን የተመዘገበ እንቅስቃሴ የለም።',
system: 'ሲስተም',
officer: 'ሹም',
applicant: 'አመልካች',
remarkOn: 'በ{{target}} ላይ ማስተካከያ ተጠይቋል',
uploaded: '{{document}} ተጭኗል',
},
documents: {
completeness: 'የሚያስፈልጉ ሰነዶች',
accepted: 'ተቀባይነት አግኝቷል',
rejected: 'ተቀባይነት አላገኘም',
accept: 'ተቀበል',
clear: 'ውሳኔ አጽዳ',
confirmReject: 'አትቀበል',
includeInAdjustment: 'መልስ',
adjustmentNote: 'አመልካቹ ምን ማስተካከል አለበት?',
nothingToJudge: 'የሚገመገም ምንም አልተጫነም',
reviewedBy: 'በ{{name}} ተገምግሟል',
saveFailed: 'ውሳኔውን ማስቀመጥ አልተቻለም',
completenessLabel: '{{value}}% የሚያስፈልጉ ሰነዶች ተጭነዋል',
missing: 'እስካሁን አልተጫነም',
flagged: 'ማስተካከያ ተጠይቋል',
view: 'እይ',
preview: 'ቅድመ እይታ',
download: 'አውርድ',
downloadShort: 'አውርድ',
reject: 'አትቀበል',
rejectReason: 'ይህ ሰነድ ለምን መስተካከል አለበት?',
reasonRequired: 'ምክንያት ያስፈልጋል',
noFile: 'ፋይል የለም',
noFileUploaded: 'እስካሁን ምንም አልተጫነም',
noInlinePreview: 'ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።',
},
done: {
completeReview: 'ግምገማ ተጠናቋል',
approveDocuments: 'ሰነዶች ጸድቀዋል',
finalApprove: 'ጸድቋል',
requestAdjustment: 'ማስተካከያ ተጠይቋል',
reject: 'ማመልከቻው አልተቀበለም',
confirmPayment: 'ክፍያ ተረጋግጧል',
hold: 'ማመልከቻው ታግዷል',
resume: 'ማመልከቻው ቀጥሏል',
escalate: 'ወደ ላይ አድጓል',
assign: 'እንደገና ተመድቧል',
scheduled: 'ምርመራ ተይዟል',
inspectionPassed: 'ምርመራ አልፏል',
inspectionFailed: 'ምርመራ ወድቋል',
},
},
error: {
reference: 'ማጣቀሻ',
retry: 'እንደገና ሞክር',
},
shortcuts: {
title: 'የቁልፍ ሰሌዳ አቋራጮች',
commandPalette: 'ሁሉንም ፈልግ',
moveRow: 'በረድፎች መካከል ተንቀሳቀስ',
openRow: 'የተመረጠውን ረድፍ ክፈት',
claimRow: 'የተመረጠውን ረድፍ ውሰድ',
dismiss: 'ምርጫ አጽዳ / ዝጋ',
help: 'ይህን ዝርዝር አሳይ',
},
designer: {
title: 'የምስክር ወረቀት ንድፍ',
subtitle: 'ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።',
licenceType: 'የፈቃድ ዓይነት',
validityYears: 'የሚቆይበት (ዓመታት)',
validityHint: 'ፈቃድ ሲሰጥ ተግባራዊ ይሆናል',
saveValidity: 'የሚቆይበትን ጊዜ አስቀምጥ',
validitySaved: 'የሚቆይበት ጊዜ ተዘምኗል',
newVersion: 'አዲስ ስሪት',
versions: 'ስሪቶች',
name: 'የስሪት ስም',
landscape: 'አግድም',
source: 'ቅንብር (Handlebars + HTML)',
variables: 'ቦታ ያዢዎች',
variablesHint: 'በጠቋሚው ቦታ ለማስገባት ይጫኑ።',
preview: 'PDF ቅድመ እይታ',
previewFailed: 'ቅድመ እይታውን ማዘጋጀት አልተቻለም',
save: 'ረቂቅ አስቀምጥ',
saved: 'ረቂቅ ተቀምጧል',
saveFirst: 'መጀመሪያ ለውጦችዎን ያስቀምጡ',
publish: 'አትም',
published: 'ንድፉ ታትሟል',
publishHint: 'ይህንን ቀጥታ የምስክር ወረቀት ንድፍ ያደርገዋል',
publishedLocked: 'ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — ከእሱ የምስክር ወረቀቶች ተሰጥተዋል። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።',
archive: 'አንሳ',
archived: 'ንድፉ ተነስቷል',
delete: 'ረቂቅ ሰርዝ',
deleted: 'ረቂቅ ተሰርዟል',
create: 'ፍጠር',
created: 'ረቂቅ ተፈጥሯል',
newHint: 'ከቀጥታ ንድፉ ወይም ይህ ዓይነት ከሌለው ከውስጠ-ግንብ ቅንብር ይጀምራል።',
empty: 'ለዚህ የፈቃድ ዓይነት እስካሁን ንድፍ የለም',
emptyBody: 'የምስክር ወረቀቶች አሁን ውስጠ-ግንብ ቅንብር ይጠቀማሉ። ለመቆጣጠር ስሪት ይፍጠሩ።',
loadFailed: 'ንድፎቹን መጫን አልተቻለም',
actionFailed: 'ተግባሩ አልተሳካም',
noPermission: 'ፈቃድ የለዎትም',
noPublishPermission: 'ንድፎችን ማተም አይችሉም',
},
};

View File

@@ -24,6 +24,30 @@ export const en = {
},
nav: {
groupLicensing: 'Licensing',
allApplications: 'All Applications',
certificateDesigner: 'Certificate Designer',
byType: 'By Type',
typeFreightForwarder: 'Freight Forwarder',
typeShippingAgent: 'Shipping Agent',
typeCombined: 'Combined SA + FF',
typeJointInvestment: 'Joint Investment',
typeMto: 'Multimodal Transport Operator',
primary: 'Primary',
destinations: 'Go to',
noResults: 'Nothing found',
commandPlaceholder: 'Search screens, applications, companies, TIN…',
pending: 'Items pending',
pendingCount_one: '{{count}} pending',
pendingCount_other: '{{count}} pending',
groupSeafarer: 'Seafarer Services',
groupVessels: 'Vessels',
groupExaminations: 'Examinations',
groupAdministration: 'Administration',
groupAccount: 'Account',
soon: 'Soon',
details: 'Details',
licenceReview: 'Licence Applications',
menu: 'MENU',
dashboard: 'Dashboard',
userManagement: 'User Management',
@@ -611,6 +635,295 @@ export const en = {
departmentRequired: 'Department is required',
},
},
queue: {
title: 'Licence applications',
search: 'Search',
searchPlaceholder: 'Company, TIN or number',
status: 'Status',
anyStatus: 'Any',
type: 'Licence type',
anyType: 'Any',
typeCol: 'Type',
statusCol: 'Status',
submittedFrom: 'Submitted from',
submittedTo: 'Submitted to',
clearFilters: 'Clear',
refresh: 'Refresh',
export: 'Export CSV',
exportTruncated: 'Export truncated',
exportTruncatedBody: 'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
exportFailed: 'Export failed',
comfortable: 'Comfortable',
compact: 'Compact',
number: 'App #',
company: 'Company',
tin: 'TIN',
submitted: 'Submitted',
sla: 'Age / SLA',
claim: 'Claim',
review: 'Review',
claimed: 'Claimed',
claimedBody: 'The application is now assigned to you.',
claimFailed: 'Could not claim',
claimRace: 'Another officer already claimed it.',
bulkClaim_one: 'Claim {{count}}',
bulkClaim_other: 'Claim {{count}}',
bulkClaimed_one: '{{count}} claimed',
bulkClaimed_other: '{{count}} claimed',
bulkClaimPartial_one: '{{count}} was already taken by another officer.',
bulkClaimPartial_other: '{{count}} were already taken by another officer.',
selectAll: 'Select all',
selectRow: 'Select {{number}}',
selectedCount_one: '{{count}} selected',
selectedCount_other: '{{count}} selected',
showing: 'Showing {{from}}{{to}} of {{total}}',
empty: 'Nothing waiting here',
emptyBody: 'New applications will appear here as they are submitted.',
emptyFiltered: 'No applications match these filters',
emptyFilteredBody: 'Try widening or clearing the filters.',
errorTitle: 'Could not load the queue',
views: {
unassigned: 'Unassigned',
mine: 'My Queue',
awaitingApplicant: 'Awaiting Applicant',
overdue: 'Overdue',
readyToIssue: 'Ready to Issue',
all: 'All',
},
},
review: {
summary: 'Summary',
officer: 'Officer',
supervisor: 'Supervisor',
officerPlaceholder: 'Select who takes this on',
noOfficers: 'No officers found',
typeToConfirm: 'Type {{number}} to confirm',
confirmMismatch: 'Does not match',
type: 'Type',
tin: 'TIN',
kind: 'Kind',
submitted: 'Submitted',
slaLabel: 'SLA',
eligibility: 'Eligibility',
statusTimeline: 'Progress',
assigned: 'Assigned',
decisionBar: 'Decision bar',
moreActions: 'More actions',
irreversible: 'Cannot be undone',
irreversibleWarning: 'This decision is final and cannot be undone from the backoffice.',
irreversibleAck: 'I understand this is final',
reasonCode: 'Reason',
reasonCodePlaceholder: 'Select a reason',
reasonDetail: 'Details for the applicant',
reasonDetailHint: 'This text is sent to the applicant verbatim.',
deficiencies: 'Items the applicant must correct',
deficienciesHint: 'Only the ticked items become editable for the applicant.',
notificationPreview: 'Message to the applicant',
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
needsCorrection: 'Needs correction',
correctionPlaceholder: 'What must the applicant correct?',
verifiedCapital: 'Verified capital (ETB)',
capitalHint: 'Minimum {{min}} — check against the bank letter',
capitalHintNoMin: 'Checked against the bank letter',
capitalLocked: 'Capital can no longer be edited at this stage.',
belowMinimum: 'Below the {{min}} minimum',
declared: 'Applicant declared',
role: 'Role',
name: 'Name',
evidence: 'Evidence',
noInspections: 'No inspection has been scheduled yet.',
unscheduled: 'Not scheduled',
inspectionResult: 'Inspection result',
findings: 'Findings',
dateTime: 'Date and time',
schedule: 'Schedule',
pickDate: 'Pick a date and time first',
passed: 'Passed',
failed: 'Failed',
round_one: 'round {{count}}',
round_other: 'round {{count}}',
theApplicant: 'the applicant',
linkCopied: 'Link copied',
actionFailed: 'Action failed',
errorTitle: 'Could not load this application',
hideActivity: 'Hide activity',
showActivity: 'Show activity',
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
tabs: {
overview: 'Overview',
financials: 'Financials',
documents: 'Documents',
staff: 'Staff',
inspection: 'Inspection',
},
actions: {
claim: 'Claim',
assign: 'Assign',
escalate: 'Escalate',
hold: 'Put on hold',
resume: 'Resume',
completeReview: 'Complete review',
approveDocuments: 'Approve documents',
scheduleInspection: 'Schedule inspection',
recordInspection: 'Record inspection result',
finalApprove: 'Approve & issue',
requestAdjustment: 'Request adjustment',
reject: 'Reject',
confirmPayment: 'Confirm payment',
print: 'Print dossier',
copyLink: 'Copy link',
downloadDocuments: 'Download all documents',
generateCertificate: 'Generate certificate',
auditTrail: 'Show audit trail',
},
disabled: {
wrongStatus: 'Not available at this stage',
notAssigned: 'Assigned to another officer',
noPermission: 'You do not have permission',
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
},
reasons: {
incompleteDocuments: 'Incomplete documents',
belowCapital: 'Capital below the required minimum',
failedInspection: 'Failed inspection',
ineligibleApplicant: 'Applicant not eligible',
duplicateApplication: 'Duplicate application',
illegibleDocument: 'Document is illegible',
expiredDocument: 'Document has expired',
missingDocument: 'Document is missing',
inconsistentDetails: 'Details do not match the documents',
awaitingThirdParty: 'Awaiting third-party confirmation',
legalProceedings: 'Subject to legal proceedings',
applicantRequest: 'Requested by the applicant',
aboveAuthority: 'Above my approval authority',
policyUnclear: 'Policy guidance needed',
conflictOfInterest: 'Conflict of interest',
},
consequences: {
fallback: 'This updates application {{number}} for {{applicant}}.',
'final-approve': 'Approves application {{number}} for {{applicant}} and starts certificate issuance.',
reject: 'Rejects application {{number}} for {{applicant}}. This ends the application.',
'request-adjustment': 'Returns application {{number}} to {{applicant}} for correction.',
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
resume: 'Returns application {{number}} to the stage it was held from.',
escalate: 'Raises application {{number}} to a supervisor for a decision.',
'confirm-payment': 'Confirms settlement for application {{number}}.',
},
notifications: {
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
'final-approve': 'Dear {{applicant}}, application {{number}} has been approved. Your certificate is being prepared.',
reject: 'Dear {{applicant}}, application {{number}} has not been approved. Please see the reason below.',
'request-adjustment': 'Dear {{applicant}}, application {{number}} needs corrections before it can proceed.',
},
activity: {
title: 'Activity & audit trail',
empty: 'No activity recorded yet.',
system: 'System',
officer: 'Officer',
applicant: 'Applicant',
remarkOn: 'Correction requested on {{target}}',
uploaded: 'Uploaded {{document}}',
},
documents: {
completeness: 'Required documents',
accepted: 'Accepted',
rejected: 'Rejected',
accept: 'Accept',
clear: 'Clear verdict',
confirmReject: 'Reject',
includeInAdjustment: 'Send back',
adjustmentNote: 'What must the applicant correct?',
nothingToJudge: 'Nothing uploaded to judge',
reviewedBy: 'Reviewed by {{name}}',
saveFailed: 'Could not save the verdict',
completenessLabel: '{{value}}% of required documents uploaded',
missing: 'Not yet uploaded',
flagged: 'Correction requested',
view: 'View',
preview: 'Preview',
download: 'Download',
downloadShort: 'Download',
reject: 'Reject',
rejectReason: 'Why must this document be corrected?',
reasonRequired: 'A reason is required',
noFile: 'No file',
noFileUploaded: 'Nothing uploaded yet',
noInlinePreview: 'This file type cannot be previewed in the browser.',
},
done: {
completeReview: 'Review completed',
approveDocuments: 'Documents approved',
finalApprove: 'Approved',
requestAdjustment: 'Adjustment requested',
reject: 'Application rejected',
confirmPayment: 'Payment confirmed',
hold: 'Application placed on hold',
resume: 'Application resumed',
escalate: 'Escalated',
assign: 'Reassigned',
scheduled: 'Inspection scheduled',
inspectionPassed: 'Inspection passed',
inspectionFailed: 'Inspection failed',
},
},
error: {
reference: 'Reference',
retry: 'Try again',
},
shortcuts: {
title: 'Keyboard shortcuts',
commandPalette: 'Search everything',
moveRow: 'Move between rows',
openRow: 'Open the selected row',
claimRow: 'Claim the selected row',
dismiss: 'Clear selection / close',
help: 'Show this list',
},
designer: {
title: 'Certificate designer',
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
licenceType: 'Licence type',
validityYears: 'Valid for (years)',
validityHint: 'Applied when a licence is issued',
saveValidity: 'Save validity',
validitySaved: 'Validity updated',
newVersion: 'New version',
versions: 'Versions',
name: 'Version name',
landscape: 'Landscape',
source: 'Template (Handlebars + HTML)',
variables: 'Placeholders',
variablesHint: 'Click to insert at the cursor.',
preview: 'Preview PDF',
previewFailed: 'Could not render the preview',
save: 'Save draft',
saved: 'Draft saved',
saveFirst: 'Save your changes first',
publish: 'Publish',
published: 'Design published',
publishHint: 'Makes this the live certificate design',
publishedLocked: 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
archive: 'Withdraw',
archived: 'Design withdrawn',
delete: 'Delete draft',
deleted: 'Draft deleted',
create: 'Create',
created: 'Draft created',
newHint: 'Starts from the live design, or the built-in layout if this type has none.',
empty: 'No design yet for this licence type',
emptyBody: 'Certificates currently use the built-in layout. Create a version to take control of it.',
loadFailed: 'Could not load the designs',
actionFailed: 'Action failed',
noPermission: 'You do not have permission',
noPublishPermission: 'You cannot publish designs',
},
};
export type Translations = typeof en;

View File

@@ -1,123 +1,28 @@
import { useCallback, useState } from "react";
import { AppShell, rem } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { logout } from "@ema-platform/auth";
import { AppHeader, AppSidebar } from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
IconAnchor,
IconBook2,
IconChartBar,
IconCreditCard,
IconFileDescription,
IconHeart,
IconLayoutDashboard,
IconShieldCheck,
IconRubberStamp,
IconSettings,
IconShip,
IconUser,
IconUsers,
IconUserShield,
IconQuestionMark,
IconClipboardList,
IconReport,
IconFilePlus,
IconGauge,
IconShieldOff,
IconStack2,
IconTruck,
IconReportAnalytics,
} from "@tabler/icons-react";
import { notify } from "@ema-platform/ui";
import { SUPPORTED_LANGUAGES } from "../i18n/config";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { useCallback, useMemo, useState } from 'react';
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BrandMark, logout } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui';
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
import { useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { NAV_SECTIONS } from './nav-config';
import { CommandPalette } from './CommandPalette';
const NAV_ITEMS: NavItem[] = [
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
{
to: "/um/user-management/dashboard",
label: "nav.userManagement",
icon: IconUserShield,
},
{ to: "/seaman-book-queue", label: "nav.seamanBookQueue", icon: IconBook2 },
{
to: "/vessel-registration-head-dashboard",
label: "nav.vesselRegistrationHeadDashboard",
icon: IconGauge,
},
{
to: "/vessel-registration-queue",
label: "nav.vesselRegistrationQueue",
icon: IconAnchor,
},
{
to: "/vessel-registration-queue/new",
label: "nav.vesselFormBuilder",
icon: IconFilePlus,
},
{
to: "/vessel-registration-report",
label: "nav.vesselRegistrationReport",
icon: IconChartBar,
},
{
to: "/vessel-ownership-transfer",
label: "nav.ownershipTransferQueue",
icon: IconFileDescription,
},
{
to: "/logistics-head-dashboard",
label: "nav.logisticsHeadDashboard",
icon: IconGauge,
},
{
to: "/freight-forwarder-license",
label: "nav.freightForwarderLicense",
icon: IconTruck,
},
{
to: "/shipping-agent-license",
label: "nav.shippingAgentLicense",
icon: IconShip,
},
{ to: "/combined-license", label: "nav.combinedLicense", icon: IconStack2 },
{
to: "/joint-investment-license",
label: "nav.jointInvestmentLicense",
icon: IconUsers,
},
{ to: "/mto-license", label: "nav.mtoLicense", icon: IconTruck },
{ to: "/waiver", label: "nav.waiver", icon: IconShieldOff },
{ to: "/coc-queue", label: "nav.cocQueue", icon: IconShieldCheck },
{
to: "/endorsement-queue",
label: "nav.endorsementQueue",
icon: IconRubberStamp,
},
{
to: "/vessel-registration-report",
label: "nav.vesselRegistrationReport",
icon: IconReportAnalytics,
},
{ 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 },
];
/**
* How often the pending-work badges refresh.
*
* Polled on a timer rather than refetched per navigation: the counts sit in
* the chrome and are visible on every screen, so tying them to route changes
* would fire a request each time an officer clicked anything.
*/
const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116;
@@ -130,8 +35,46 @@ export function BackofficeLayout() {
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { can } = usePermissions();
const displayName = user?.name?.en || user?.username || "";
// Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache.
const { data: counts } = useGetQueueCountsQuery(undefined, {
pollingInterval: BADGE_POLL_MS,
refetchOnMountOrArgChange: false,
});
const sections = useMemo<NavSection[]>(() => {
const withBadges = NAV_SECTIONS.map((section) => ({
...section,
items: section.items.map((item) =>
item.to === '/licence-review' && counts?.unassigned
? { ...item, badge: counts.unassigned }
: item,
),
}));
return filterByPermissions(
withBadges,
// `can` already fails open when the token carries no permission claim,
// so this only ever removes items we are sure the user cannot use.
withBadges
.flatMap((section) => section.items)
.flatMap((item) => [item, ...(item.children ?? [])])
.flatMap((item) => item.permissions ?? [])
.filter((permission) => can([permission])),
);
}, [counts?.unassigned, can]);
/** Flat list used for breadcrumbs and active-route lookup. */
const navItems = useMemo<NavItem[]>(
() =>
sections.flatMap((section) =>
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
),
[sections],
);
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
? displayName
.split(/\s+/)
@@ -146,14 +89,29 @@ export function BackofficeLayout() {
navigate("/login");
}, [dispatch, navigate]);
const segments = location.pathname.split("/").filter(Boolean);
const segments = location.pathname.split('/').filter(Boolean);
// Label each crumb from the nav item it corresponds to, falling back to a
// readable form of the path segment. Every crumb was previously labelled
// "Dashboard", which made the trail useless.
const crumbs = [
{ label: t("nav.dashboard"), path: "/dashboard" },
...segments
.map((_, i) => "/" + segments.slice(0, i + 1).join("/"))
.filter((path) => path !== "/dashboard")
.filter((path) => !path.startsWith("/um"))
.map((path) => ({ label: t("nav.dashboard"), path })),
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
.filter((path) => path !== '/dashboard')
.filter((path) => !path.startsWith('/um'))
.map((path) => {
const match = navItems.find((item) => item.to === path);
if (match) return { label: t(match.label), path };
const segment = path.split('/').pop() ?? '';
// Ids get a generic label rather than a raw uuid in the trail.
const isId = /^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment);
return {
label: isId
? t('nav.details', 'Details')
: segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
path,
};
}),
];
const go = (item: NavItem) => {
@@ -211,65 +169,21 @@ export function BackofficeLayout() {
{!isSidebar && (
<div
style={{
display: "flex",
alignItems: "center",
gap: rem(2),
padding: "0 32px",
display: 'flex',
alignItems: 'center',
padding: '0 32px',
height: 42,
borderTop: "1px solid var(--mantine-color-gray-1)",
overflowX: "auto",
borderTop: '1px solid var(--mantine-color-gray-1)',
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>
);
})}
{/* Grouped dropdowns. Previously every destination rendered as a
sibling button in one horizontally scrolling row. */}
<AppTopNav
navItems={sections}
activePath={location.pathname}
onNavigate={go}
/>
</div>
)}
</AppShell.Header>
@@ -285,13 +199,14 @@ export function BackofficeLayout() {
}}
>
<AppSidebar
navItems={NAV_ITEMS}
navItems={sections}
collapsed={collapsed}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={go}
brandName={t("app.name")}
brandSubtitle={t("app.authority")}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
brandLogo={<BrandMark size={32} />}
/>
</AppShell.Navbar>
)}
@@ -301,6 +216,9 @@ export function BackofficeLayout() {
<Outlet />
</div>
</AppShell.Main>
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
<CommandPalette sections={sections} />
</AppShell>
);
}

View File

@@ -0,0 +1,96 @@
import { useMemo, useState } from 'react';
import { Spotlight, type SpotlightActionData } from '@mantine/spotlight';
import { useDebouncedValue } from '@mantine/hooks';
import { IconFileText, IconSearch } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { flattenNav, type NavSection } from '@ema-platform/ui';
import { useGetAllApplicationsQuery } from '@ema-platform/api';
/** Long enough that typing a company name does not fire a request per keystroke. */
const SEARCH_DEBOUNCE_MS = 250;
/** Below this, a server search matches too much to be useful. */
const MIN_SEARCH_LENGTH = 2;
interface CommandPaletteProps {
/** Already permission-filtered, so the palette cannot reach a hidden route. */
sections: NavSection[];
}
/**
* ⌘K search over every destination and recent application.
*
* With twenty-plus destinations plus five licence types, hunting through
* nested menus is the slow path. This makes nesting cheap: anything reachable
* by clicking is reachable by typing, including applications by number,
* company or TIN.
*/
export function CommandPalette({ sections }: CommandPaletteProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debounced] = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const term = debounced.trim();
// Only hits the API once the palette is open and the query is meaningful.
const { data: applications } = useGetAllApplicationsQuery(
{ search: term, take: 8 },
{ skip: term.length < MIN_SEARCH_LENGTH },
);
const destinationActions = useMemo<SpotlightActionData[]>(
() =>
flattenNav(sections)
.filter((item) => item.to && !item.soon)
.map((item) => ({
id: item.to as string,
label: t(item.label),
description: item.to,
leftSection: <item.icon size={18} stroke={1.6} />,
onClick: () => navigate(item.to as string),
})),
[sections, navigate, t],
);
const applicationActions = useMemo<SpotlightActionData[]>(
() =>
(applications?.items ?? []).map((app) => ({
id: `application-${app.id}`,
label: app.companyName ?? app.applicationNumber,
description: [app.applicationNumber, app.tinNumber]
.filter(Boolean)
.join(' · '),
leftSection: <IconFileText size={18} stroke={1.6} />,
onClick: () => navigate(`/licence-review/${app.id}`),
})),
[applications, navigate],
);
return (
<Spotlight
query={query}
onQueryChange={setQuery}
actions={[
{
group: t('nav.destinations', 'Go to'),
actions: destinationActions,
},
{
group: t('nav.applications', 'Applications'),
actions: applicationActions,
},
]}
shortcut={['mod + K']}
nothingFound={t('nav.noResults', 'Nothing found')}
highlightQuery
searchProps={{
leftSection: <IconSearch size={18} stroke={1.6} />,
placeholder: t(
'nav.commandPlaceholder',
'Search screens, applications, companies, TIN…',
),
}}
/>
);
}

View File

@@ -0,0 +1,144 @@
import {
IconAnchor,
IconBook2,
IconChartBar,
IconClipboardList,
IconCreditCard,
IconFileDescription,
IconFilePlus,
IconGauge,
IconHeart,
IconLayoutDashboard,
IconListCheck,
IconMapPin,
IconQuestionMark,
IconReport,
IconRosetteDiscountCheck,
IconRubberStamp,
IconSettings,
IconShieldCheck,
IconShieldOff,
IconShip,
IconTruck,
IconUsers,
IconUserShield,
} from '@tabler/icons-react';
import type { NavSection } from '@ema-platform/ui';
/**
* Permission keys mirrored from the API's `LICENSE_PERMISSIONS`.
*
* Kept as literals rather than imported: the backoffice bundle must not pull
* in server code, and these strings are a published contract — the IAM seed
* and every `PermissionGuard([...])` already read from the same list.
*/
export const PERMISSIONS = {
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
VIEW_APPLICATIONS: 'can:View:license-applications',
VIEW_LICENSE_TYPES: 'can:View:license-types',
VIEW_PAYMENTS: 'can:View:license-payments',
VIEW_TEMPLATES: 'can:View:license-templates',
UPDATE_TEMPLATE: 'can:update:license-template',
PUBLISH_TEMPLATE: 'can:publish:license-template',
} as const;
/**
* The backoffice information architecture.
*
* Six top-level groups, none deeper than one level of nesting. `soon` marks
* screens with no backend behind them, so a reviewer can tell at a glance what
* actually works.
*/
export const NAV_SECTIONS: NavSection[] = [
{
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
},
{
label: 'nav.groupLicensing',
items: [
{
to: '/licence-review',
label: 'nav.allApplications',
icon: IconListCheck,
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE],
},
{
// A disclosure, not a destination — each child deep-links the grid to
// one type, which is a facet of the same workspace.
label: 'nav.byType',
icon: IconTruck,
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
children: [
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck },
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip },
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription },
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers },
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
],
},
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [PERMISSIONS.VIEW_TEMPLATES],
},
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
{
to: '/logistics-head-dashboard',
label: 'nav.logisticsHeadDashboard',
icon: IconGauge,
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [PERMISSIONS.VIEW_PAYMENTS],
},
],
},
{
label: 'nav.groupSeafarer',
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
],
},
{
label: 'nav.groupExaminations',
items: [
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
],
},
{
label: 'nav.groupAdministration',
items: [
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
{
to: '/configuration',
label: 'nav.configuration',
icon: IconSettings,
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES],
},
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
],
},
// `/profile` deliberately absent: it is a property of the signed-in user,
// not a destination in the authority's workload, and now lives in the
// AppHeader user menu alongside sign-out.
];

View File

@@ -1,7 +1,7 @@
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthConfigProvider } from '@ema-platform/auth';
import { AuthBootstrap, AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { i18n } from '../i18n/config';
@@ -27,7 +27,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<I18nextProvider i18n={i18n}>
<MantineThemeProvider>{children}</MantineThemeProvider>
<MantineThemeProvider>
<AuthBootstrap>{children}</AuthBootstrap>
</MantineThemeProvider>
</I18nextProvider>
</AuthConfigProvider>
</QueryClientProvider>

View File

@@ -34,22 +34,12 @@ import { VesselRegistrationFormBuilderPage } from '../features/vessel-registrati
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
import { FreightForwarderLicenseQueuePage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
import { FreightForwarderLicenseReviewPage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseReviewPage';
import { ShippingAgentLicenseQueuePage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
import { ShippingAgentLicenseReviewPage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseReviewPage';
import { CombinedLicenseQueuePage } from '../features/combined-license/pages/CombinedLicenseQueuePage';
import { CombinedLicenseReviewPage } from '../features/combined-license/pages/CombinedLicenseReviewPage';
import { JointInvestmentLicenseQueuePage } from '../features/joint-investment-license/pages/JointInvestmentLicenseQueuePage';
import { JointInvestmentLicenseReviewPage } from '../features/joint-investment-license/pages/JointInvestmentLicenseReviewPage';
import { MtoLicenseQueuePage } from '../features/mto-license/pages/MtoLicenseQueuePage';
import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseReviewPage';
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
const router = createBrowserRouter([
{
@@ -96,16 +86,23 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
{ path: 'freight-forwarder-license', element: <FreightForwarderLicenseQueuePage /> },
{ path: 'freight-forwarder-license/:id', element: <FreightForwarderLicenseReviewPage /> },
{ path: 'shipping-agent-license', element: <ShippingAgentLicenseQueuePage /> },
{ path: 'shipping-agent-license/:id', element: <ShippingAgentLicenseReviewPage /> },
{ path: 'combined-license', element: <CombinedLicenseQueuePage /> },
{ path: 'combined-license/:id', element: <CombinedLicenseReviewPage /> },
{ path: 'joint-investment-license', element: <JointInvestmentLicenseQueuePage /> },
{ path: 'joint-investment-license/:id', element: <JointInvestmentLicenseReviewPage /> },
{ path: 'mto-license', element: <MtoLicenseQueuePage /> },
{ path: 'mto-license/:id', element: <MtoLicenseReviewPage /> },
// Config-driven review workspace, shared by every licence type.
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
{ path: 'licence-review', element: <LicenseQueuePage /> },
// Deep link into the grid with the type facet pinned, so "Freight
// Forwarder" in the nav is a filtered view rather than a page.
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'shipping-agent-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'combined-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'combined-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'joint-investment-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'joint-investment-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'mto-license', element: <Navigate to="/licence-review" replace /> },
{ path: 'mto-license/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'waiver', element: <WaiverQueuePage /> },
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
],

View File

@@ -2,8 +2,15 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type LayoutMode = 'top' | 'sidebar';
/**
* Row height in tables. Officers who work a queue all day want more rows per
* screen; occasional users want the breathing room.
*/
export type Density = 'comfortable' | 'compact';
interface PreferencesState {
layoutMode: LayoutMode;
density: Density;
}
const PREFERENCES_KEY = 'ema-backoffice-preferences';
@@ -11,15 +18,25 @@ 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' };
if (stored) {
// Merge over the defaults so a preferences blob written before a new
// key existed does not come back with that key undefined.
return { layoutMode: 'sidebar', density: 'comfortable', ...JSON.parse(stored) };
}
} catch {
// Corrupt or unavailable storage (private mode) — fall through to the default.
}
// The sidebar is the grouped, scannable layout; the top strip puts all
// ~20 destinations in one horizontally-scrolling row.
return { layoutMode: 'sidebar', density: 'comfortable' };
};
const savePreferences = (state: PreferencesState) => {
try {
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
} catch {}
} catch {
// Storage full or unavailable — the preference just will not persist.
}
};
const initialState: PreferencesState = loadPreferences();
@@ -32,8 +49,12 @@ const preferencesSlice = createSlice({
state.layoutMode = action.payload;
savePreferences(state);
},
setDensity(state, action: PayloadAction<Density>) {
state.density = action.payload;
savePreferences(state);
},
},
});
export const { setLayoutMode } = preferencesSlice.actions;
export const { setLayoutMode, setDensity } = preferencesSlice.actions;
export const preferencesReducer = preferencesSlice.reducer;

View File

@@ -3,10 +3,32 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/spotlight/styles.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
/**
* Branding handed to the vendored `@tria-plc/iamui` user-management module,
* which reads it off `window` at import time. Declared here because that
* package ships no ambient type for it.
*/
declare global {
interface Window {
__USER_MANAGEMENT_BRANDING__: {
appName: string;
organizationName: string;
logoSrc: string;
logoAlt: string;
homePath: string;
moduleBasePath: string;
backToAppPath: string;
backToAppLabel: string;
cssVariables: Record<string, string>;
};
}
}
document.title = 'EMA Backoffice';
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');

View File

@@ -5,28 +5,50 @@
*, *::before, *::after { box-sizing: border-box; }
:root {
--ema-scrollbar-light: #c1c1c1;
--ema-scrollbar-dark: #374151;
--ema-scrollbar-track-light: #f5f8fc;
--ema-scrollbar-track-dark: #0e1521;
}
/* ---------------------------------------------------------------------------
Print — the review dossier.
[data-mantine-color-scheme='light'] body {
--ema-scrollbar-thumb: var(--ema-scrollbar-light);
--ema-scrollbar-track: var(--ema-scrollbar-track-light);
}
[data-mantine-color-scheme='dark'] body {
--ema-scrollbar-thumb: var(--ema-scrollbar-dark);
--ema-scrollbar-track: var(--ema-scrollbar-track-dark);
}
An officer printing a review wants the application, not the application
plus the chrome around it. Navigation, the Decision Bar and the collapsible
rails are all interactive surfaces with no meaning on paper, so they are
dropped and the centre column is given the full width.
--------------------------------------------------------------------------- */
@media print {
.mantine-AppShell-navbar,
.mantine-AppShell-header,
[role='region'][aria-label='Decision bar'],
.mantine-Drawer-root,
.mantine-Modal-root {
display: none !important;
}
html {
scrollbar-color: var(--ema-scrollbar-thumb) var(--ema-scrollbar-track);
scrollbar-width: thin;
}
.mantine-AppShell-main {
padding: 0 !important;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--ema-scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--ema-scrollbar-thumb); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { opacity: 0.8; }
/* Tabs print flattened: a printed dossier that shows one tab's worth of a
six-tab application is missing five sixths of the record. */
.mantine-Tabs-panel {
display: block !important;
}
.mantine-Tabs-list {
display: none !important;
}
/* Sticky positioning collapses badly across page breaks. */
[style*='position: sticky'],
[style*='position:sticky'] {
position: static !important;
}
/* Keep a record entry from being split across two sheets. */
.mantine-Paper-root,
.mantine-Card-root,
.mantine-Table-tr {
break-inside: avoid;
}
body {
background: #fff !important;
}
}

View File

@@ -13,6 +13,16 @@ export default defineConfig({
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
alias: {
// The vendored user-management UI pulls in file-type → token-types,
// which imports `node:buffer`. Point it at the browser polyfill so the
// production build resolves it instead of failing on a Node built-in.
'node:buffer': 'buffer',
},
},
define: {
// `buffer` expects a `global` binding that browsers do not provide.
global: 'globalThis',
},
build: {
outDir: '../../dist/apps/backoffice',

View File

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

View File

@@ -1,447 +1,21 @@
import { useRef, useState } from 'react';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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">
<AmharicDatePicker label="Issue Date" required value={issueDate} onChange={setIssueDate} size="sm" />
{item.refreshYears > 0 && (
<AmharicDatePicker label={`Expiry Date (${item.refreshYears}-yr refresh)`} value={expiryDate} onChange={setExpiryDate} 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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}
<Container size="lg" py="xl">
<FeatureUnavailable
title="Basic Safety Training"
description="BST records are not connected to the backend yet."
/>
</Stack>
</Container>
);
}
export default BasicSafetyTrainingPage;

View File

@@ -1,266 +1,21 @@
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 { useErrorHandler } from '@ema-platform/ui';
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);
}
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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 { handleError } = useErrorHandler();
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) {
handleError(err);
} 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) {
handleError(err);
}
};
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="My certificates"
description="Seafarer certificates are not connected to the backend yet."
/>
</Container>
);
}
export default CertificatesPage;

View File

@@ -1,461 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Company Information' },
{ label: 'Shipping Agreement & Bank Letter' },
{ label: 'Vehicle, Office & Terminal' },
{ label: 'Employees' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
accept?: string;
}
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
{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',
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), minWidth: rem(24),
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>
);
}
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Signed agreement with a shipping company', required: true, icon: IconFileDescription },
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
{ key: 'canvasserDocs', label: 'Canvasser Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
{ key: 'adminDocs', label: 'Administrative Staff Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
{ key: 'ceoDocs', label: 'CEO / General Manager Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
];
export function CombinedLicenseApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [companyName, setCompanyName] = useState('');
const [tradeName, setTradeName] = useState('');
const [tinNumber, setTinNumber] = useState('');
const [commercialRegNumber, setCommercialRegNumber] = useState('');
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
const [businessAddress, setBusinessAddress] = useState('');
const [officeAddress, setOfficeAddress] = useState('');
const [applicantType, setApplicantType] = useState<string | null>(null);
const [ownershipType, setOwnershipType] = useState<string | null>(null);
const [shippingCompanyName, setShippingCompanyName] = useState('');
const [agreementRefNumber, setAgreementRefNumber] = useState('');
const [bankName, setBankName] = useState('');
const [accountHolderName, setAccountHolderName] = useState('');
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
const [plateNumber, setPlateNumber] = useState('');
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
const [terminalName, setTerminalName] = useState('');
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
const [bookingClerkName, setBookingClerkName] = useState('');
const [canvasserName, setCanvasserName] = useState('');
const [ceoName, setCeoName] = useState('');
const [adminStaffName, setAdminStaffName] = useState('');
const [transit1Name, setTransit1Name] = useState('');
const [transit2Name, setTransit2Name] = useState('');
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return (
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
);
if (active === 1) return (
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
!!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000
);
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
if (active === 3) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
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 submitTrigger({
url: '/logistics-licenses/combined',
method: 'POST',
body: {
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
businessAddress, officeAddress, applicantType, ownershipType,
shippingCompanyName, agreementRefNumber, bankName, accountHolderName, capitalAmount,
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
bookingClerkName, canvasserName, ceoName, adminStaffName, transit1Name, transit2Name,
},
}).unwrap();
notify.success('Combined License application submitted successfully!');
navigate('/combined-license');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Combined License Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<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>
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Your account email and phone number will be used automatically no need to re-enter them here.
</Alert>
<SectionHead title="Company Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<SectionHead title="Shipping Company Agreement" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} mt="md">
Bank letter must show a minimum combined capital of 1,500,000 ETB (no separate amounts required per license type).
</Alert>
<SectionHead title="Bank Letter / Capital Evidence" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
<NumberInput
label="Capital Amount (ETB)"
required
min={0}
value={capitalAmount}
onChange={setCapitalAmount}
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
/>
</SimpleGrid>
</Stack>
)}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Vehicle Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Office Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
</SimpleGrid>
<SectionHead title="Terminal Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented / Agreement']} value={terminalOwnership} onChange={setTerminalOwnership} />
</SimpleGrid>
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
This combined license requires both Shipping Agent staff roles and two ERB-qualified transit/customs employees.
</Alert>
<SectionHead title="Shipping Agent Employee Profiles" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Freight Forwarder Transit Employees" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{active === 5 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Company Name" value={companyName} />
<ReviewRow label="Trade Name" value={tradeName} />
<ReviewRow label="TIN Number" value={tinNumber} />
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
<ReviewRow label="Business Address" value={businessAddress} />
<ReviewRow label="Office Address" value={officeAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Shipping Agreement & Bank Letter</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
<ReviewRow label="Bank Name" value={bankName} />
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
<ReviewRow label="Terminal" value={terminalName} />
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
<ReviewRow label="Canvasser" value={canvasserName} />
<ReviewRow label="CEO" value={ceoName} />
<ReviewRow label="Administrative Staff" value={adminStaffName} />
<ReviewRow label="Transit Employee 1" value={transit1Name} />
<ReviewRow label="Transit Employee 2" value={transit2Name} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/combined-license') : prev}>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
) : (
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
Submit Application
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,218 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconCertificate,
IconCheck,
IconCircleCheck,
IconClockHour4,
IconDownload,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
interface CombinedLicenseApplication {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
};
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
export function CombinedLicensePage() {
const navigate = useNavigate();
const [application, setApplication] = useState<CombinedLicenseApplication | null>(null);
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/combined/my', method: 'GET' })
.unwrap()
.then((data) => setApplication(data))
.catch(() => {/* no application yet */});
}, [fetchTrigger]);
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Combined Shipping Agent + Freight Forwarder License</Title>
<Text fz="sm" c="dimmed">Apply for a single license covering both shipping agency and freight forwarding services</Text>
</div>
</Group>
{!application && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Apply for a Combined License</Text>
<Text fz="sm" c="dimmed">Fulfill both Shipping Agent and Freight Forwarder requirements in a single application</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Shipping company agreement" />
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
<RequirementItem label="Office title deed or office rental agreement" />
<RequirementItem label="Terminal agreement or terminal title deed" />
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff, and CEO profiles" />
<RequirementItem label="Two ERB-qualified transit/customs employees" />
<RequirementItem label="Passport-size photo for certificate printing" />
</Stack>
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/combined-license/apply')}>
Start Application
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About the Combined License</Text>
</Group>
<Text fz="sm" c="dimmed">
This single license lets you legally provide both freight forwarding and shipping agency services.
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
The certificate is valid for <strong>one year</strong> and must be renewed annually.
</Text>
</Paper>
</>
)}
{application && (
<>
{application.status === 'Resubmit Required' && (
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
{application.remarks || 'Please correct the requested information and resubmit.'}
</Alert>
)}
{application.status === 'Rejected' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
{application.remarks || 'Your application was rejected.'}
</Alert>
)}
{application.status === 'Payment Pending' && (
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
Your application has been approved. Please pay 1000 ETB to receive your certificate.
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
</Alert>
)}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{application.companyName}</Text>
<Text fz="xs" c="dimmed">{application.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
</Group>
{application.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{application.remarks}</Text>
</>
)}
</Paper>
{application.status !== 'Certificate Issued' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{application.status === 'Certificate Issued' && (
<div>
<Group gap="xs" mb="sm">
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">Issued Certificate</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your Combined License certificate is ready. Valid until {application.expiryDate}.
</Alert>
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
<div>
<Text fw={600} fz="sm">Combined License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,146 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Box,
Button,
Card,
FileButton,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
}
const RENEWAL_DOCS: DocSlot[] = [
{ key: 'prevCertificate', label: 'Previous Combined License Certificate', description: 'Your current/expiring certificate', required: true },
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months (Freight Forwarder requirement)', required: true },
{ key: 'clearanceEvidence', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months (Shipping Agent requirement)', required: true },
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
];
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
export function CombinedLicenseRenewalPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [submitting, setSubmitting] = useState(false);
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({ url: '/logistics-licenses/combined/renew', method: 'POST', body: {} }).unwrap();
notify.success('Renewal request submitted successfully!');
navigate('/combined-license');
} catch {
notify.error('Renewal submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
Back
</Button>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Renew Combined License</Title>
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
</div>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Renewal applies the controls of both the Shipping Agent and Freight Forwarder licenses. If your vehicle or
office rental agreement has expired since your last submission, an updated agreement is required.
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
<Stack gap="md">
{RENEWAL_DOCS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</Stack>
<Group justify="flex-end" mt="xl">
<Button
color="teal"
leftSection={<IconCheck size={16} />}
loading={submitting}
disabled={!canSubmit}
onClick={handleSubmit}
>
Submit Renewal Request
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,239 +1,583 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Group,
Loader,
Paper,
Progress,
SimpleGrid,
Stack,
Table,
Text,
ThemeIcon,
Title,
UnstyledButton,
useMantineTheme,
rem,
Tooltip,
} from '@mantine/core';
import {
IconAlertCircle,
IconBook2,
IconChevronRight,
IconAlertTriangle,
IconCertificate,
IconClipboardList,
IconFileCheck,
IconHeart,
IconLifebuoy,
IconShip,
IconClockHour4,
IconCreditCard,
IconDownload,
IconFileText,
IconShieldCheck,
IconUserPlus,
IconBell,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
import {
APPLICANT_ACTION_STATUSES,
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
TERMINAL_STATUSES,
localized,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
} from '@ema-platform/api';
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
// ---------------------------------------------------------------------------
// 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 },
];
/**
* The applicant's home screen.
*
* Ordered by what the applicant needs from it: first anything blocked on them,
* then a read of where their applications stand, then the licence catalogue,
* then the licences they already hold. Every figure is the signed-in user's
* own data — there are no illustrative numbers on this page.
*/
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 },
];
/** Days before expiry at which a licence is worth flagging. */
const EXPIRY_WARNING_DAYS = 60;
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' },
];
function daysUntil(date: string): number {
const ms = new Date(date).getTime() - Date.now();
return Math.ceil(ms / 86_400_000);
}
function formatMoney(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return 'No fee';
const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee';
return `${value.toLocaleString('en-US')} ${currency}`;
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
export function DashboardPage() {
const navigate = useNavigate();
const theme = useMantineTheme();
const displayName = useSelector(
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
state.auth.user?.name?.en || state.auth.user?.username || '',
);
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;
const { data: applications, isLoading } = useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl, { isLoading: isDownloading }] =
useGetCertificateUrlMutation();
const items = useMemo(() => applications?.items ?? [], [applications]);
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
const needsMe = items.filter((a) =>
APPLICANT_ACTION_STATUSES.includes(a.status),
);
const inProgress = items.filter(
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
);
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
const expiringSoon = activeLicenses.filter((l) => {
const days = daysUntil(l.expiryDate);
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
});
async function downloadCertificate(license: IssuedLicense) {
const result = await getCertificateUrl(license.id).unwrap();
window.open(result.url, '_blank', 'noopener');
}
if (isLoading) {
return (
<Center h={400}>
<Loader />
</Center>
);
}
return (
<Stack gap="lg">
{/* Hero */}
<Paper
radius="lg"
p="xl"
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap="md" maw={560}>
<Stack gap={6}>
<Title order={2} c="white" fz={26}>
Welcome to the EMA Seafarer Portal
</Title>
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
Manage your seafarer profile, track certificates, apply for your Seaman Book
and monitor your maritime credentials all in one place.
</Text>
</Stack>
</Stack>
<Center
visibleFrom="sm"
w={120}
h={120}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.15)', flexShrink: 0 }}
<Container size="xl" py="lg">
<Stack gap="xl">
<Hero
displayName={displayName}
applicationCount={items.length}
licenseCount={activeLicenses.length}
/>
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
<ProfileCompletionNudge />
{needsMe.length > 0 && (
<ActionRequired applications={needsMe} navigate={navigate} />
)}
{expiringSoon.length > 0 && (
<Alert
variant="light"
color="orange"
radius="md"
icon={<IconClockHour4 size={18} />}
title={
expiringSoon.length === 1
? 'A licence is expiring soon'
: `${expiringSoon.length} licences are expiring soon`
}
>
<IconShip size={62} color="white" stroke={1.4} />
</Center>
</Group>
</Paper>
<Text size="sm">
{expiringSoon
.map(
(l) =>
`${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`,
)
.join(' · ')}
</Text>
</Alert>
)}
{/* 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)}
<StatRow
inProgress={inProgress.length}
needsMe={needsMe.length}
activeLicenses={activeLicenses.length}
expiringSoon={expiringSoon.length}
/>
<Section
title="Apply for a licence"
description="Choose the licence that matches the service your company provides."
>
{alert.message}
</Alert>
))}
<LicenseCatalogue />
</Section>
{/* 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>
<Section
title="My applications"
action={
items.length > 0 ? (
<Anchor
size="sm"
onClick={() => navigate('/licensing/applications')}
>
View all
</Anchor>
) : undefined
}
>
{items.length === 0 ? (
<EmptyCard message="You have not filed any applications yet. Pick a licence above to get started." />
) : (
<ApplicationTable
applications={items.slice(0, 6)}
navigate={navigate}
/>
)}
</Section>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
{/* Profile completeness */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700}>Registration Checklist</Text>
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
{completeness}% complete
</Badge>
</Group>
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
<Stack gap="xs">
{PROFILE_STEPS.map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon
size={20}
radius="xl"
variant={step.done ? 'filled' : 'light'}
color={step.done ? 'teal' : 'gray'}
>
<IconFileCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'} td={step.done ? undefined : undefined}>
{step.label}
</Text>
{step.done && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
</Group>
))}
</Stack>
</Paper>
{/* BST tracker */}
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md">
<Text fw={700}>Basic Safety Training</Text>
<Badge variant="light" color={bstDone === BST_ITEMS.length ? 'teal' : 'orange'}>
{bstDone} / {BST_ITEMS.length}
</Badge>
</Group>
<Stack gap="xs">
{BST_ITEMS.map((item) => (
<Group key={item.label} gap="xs" wrap="nowrap">
<ThemeIcon size={20} radius="xl" variant={item.done ? 'filled' : 'light'} color={item.done ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
<IconShieldCheck size={12} />
</ThemeIcon>
<Text fz="sm" flex={1} c={item.done ? undefined : 'dimmed'} style={{ lineHeight: 1.3 }}>
{item.label}
</Text>
{item.done && item.expiry && (
<Text fz="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>exp {item.expiry}</Text>
)}
{!item.done && (
<Badge size="xs" color="red" variant="light">Missing</Badge>
)}
</Group>
))}
</Stack>
</Paper>
</SimpleGrid>
{/* Quick actions */}
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">Quick Actions</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm">
<QuickAction icon={IconUserPlus} color="emaPrimary" label="New Seafarer Registration" sub="Register a new seafarer profile" onClick={() => navigate('/seafarer-registration')} />
<QuickAction icon={IconBook2} color="blue" label="Apply for Seaman Book" sub="Submit your Seaman Book application" onClick={() => navigate('/seaman-book')} />
<QuickAction icon={IconShieldCheck} color="teal" label="Certificates (CoC / CoP)" sub="Apply for STCW certificates" onClick={() => navigate('/certificates')} />
<QuickAction icon={IconHeart} color="red" label="Medical Certificate" sub="Upload or renew your medical certificate" onClick={() => navigate('/documents')} />
<QuickAction icon={IconClipboardList} color="violet" label="Document Vault" sub="Manage all your uploaded documents" onClick={() => navigate('/documents')} />
<QuickAction icon={IconLifebuoy} color="orange" label="Help & Support" sub="Get assistance from EMA staff" onClick={() => navigate('/support')} />
</SimpleGrid>
</Paper>
</Stack>
{heldLicenses.length > 0 && (
<Section title="My licences">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => (
<LicenseCard
key={license.id}
license={license}
isDownloading={isDownloading}
onDownload={() => downloadCertificate(license)}
/>
))}
</SimpleGrid>
</Section>
)}
</Stack>
</Container>
);
}
function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
// --------------------------------------------------------------- components
function Hero({
displayName,
applicationCount,
licenseCount,
}: {
displayName: string;
applicationCount: number;
licenseCount: number;
}) {
const summary =
applicationCount === 0 && licenseCount === 0
? 'Apply for a maritime or logistics licence and track it through to issue.'
: `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`;
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} />
<Paper
radius="lg"
p="xl"
style={{
background:
'linear-gradient(135deg, var(--mantine-color-emaPrimary-7) 0%, var(--mantine-color-emaPrimary-9) 55%, var(--mantine-color-emaTeal-8) 100%)',
color: 'white',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" style={{ opacity: 0.85 }}>
Ethiopian Maritime Authority
</Text>
<Title order={2} mt={4} c="white">
{displayName ? `Welcome back, ${displayName}` : 'Welcome back'}
</Title>
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
{summary}
</Text>
</Box>
<ThemeIcon
size={56}
radius="md"
variant="transparent"
c="white"
visibleFrom="sm"
>
<IconShieldCheck size={44} stroke={1.3} />
</ThemeIcon>
</Group>
</Paper>
);
}
function ActionRequired({
applications,
navigate,
}: {
applications: LicenseApplication[];
navigate: (path: string) => void;
}) {
return (
<Card withBorder radius="md" padding="md" bg="orange.0">
<Group gap="xs" mb="sm">
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
<IconAlertTriangle size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Waiting on you
</Text>
</Group>
<Stack gap="xs">
{applications.map((app) => {
const detail = detailFor(app);
return (
<Paper key={app.id} radius="sm" p="sm" withBorder bg="white">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color={STATUS_COLORS[app.status]}
radius="md"
>
{app.status === 'PAYMENT_PENDING' ? (
<IconCreditCard size={16} />
) : (
<IconClipboardList size={16} />
)}
</ThemeIcon>
<Box>
<Text size="sm" fw={600}>
{app.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{detail.message}
</Text>
</Box>
</Group>
<Button
size="xs"
color={detail.color}
onClick={() => navigate(detail.path)}
>
{detail.cta}
</Button>
</Group>
</Paper>
);
})}
</Stack>
</Card>
);
}
/** What the applicant has to do next, and where that happens. */
function detailFor(app: LicenseApplication): {
message: string;
cta: string;
color: string;
path: string;
} {
const typeKey = app.licenseType?.key;
const wizard = typeKey
? `/licensing/${typeKey}/applications/${app.id}`
: '/licensing/applications';
switch (app.status) {
case 'RESUBMIT_REQUIRED':
return {
message: 'A reviewer asked for corrections before this can proceed.',
cta: 'Fix now',
color: 'orange',
path: wizard,
};
case 'PAYMENT_PENDING':
return {
message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`,
cta: 'Pay now',
color: 'yellow',
path: '/licensing/applications',
};
default:
return {
message: 'This application is still a draft and has not been filed.',
cta: 'Continue',
color: 'blue',
path: wizard,
};
}
}
function StatRow({
inProgress,
needsMe,
activeLicenses,
expiringSoon,
}: {
inProgress: number;
needsMe: number;
activeLicenses: number;
expiringSoon: number;
}) {
const stats = [
{ label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' },
];
return (
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{stats.map((stat) => (
<Card key={stat.label} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={30} fw={700} lh={1.2} mt={4}>
{stat.value}
</Text>
</Box>
<ThemeIcon variant="light" color={stat.color} radius="md" size="lg">
<stat.icon size={18} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
);
}
function Section({
title,
description,
action,
children,
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Box>
<Group justify="space-between" align="flex-end" mb="sm">
<Box>
<Title order={4}>{title}</Title>
{description && (
<Text size="sm" c="dimmed" mt={2}>
{description}
</Text>
)}
</Box>
{action}
</Group>
{children}
</Box>
);
}
function ApplicationTable({
applications,
navigate,
}: {
applications: LicenseApplication[];
navigate: (path: string) => void;
}) {
return (
<Card withBorder radius="md" padding={0}>
<Table.ScrollContainer minWidth={640}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Application</Table.Th>
<Table.Th>Licence</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={180}>Progress</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{applications.map((app) => (
<Table.Tr
key={app.id}
style={{ cursor: 'pointer' }}
onClick={() =>
navigate(
app.licenseType?.key
? `/licensing/${app.licenseType.key}/applications/${app.id}`
: '/licensing/applications',
)
}
>
<Table.Td>
<Text size="sm" fw={600}>
{app.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{app.companyName ?? '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{localized(app.licenseType?.name) || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color={STATUS_COLORS[app.status]}>
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Progress
value={STATUS_PROGRESS[app.status]}
color={STATUS_COLORS[app.status]}
size="sm"
radius="xl"
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
);
}
function LicenseCard({
license,
isDownloading,
onDownload,
}: {
license: IssuedLicense;
isDownloading: boolean;
onDownload: () => void;
}) {
const days = daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
return (
<Card withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text size="sm" fw={600}>
{localized(license.licenseType?.name) || 'Licence'}
</Text>
<Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber}
</Text>
</Box>
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
>
{expired ? 'Expired' : license.status}
</Badge>
</Group>
<Divider my="sm" />
<Group justify="space-between" align="center">
<Box>
<Text size="xs" c="dimmed">
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}>
{formatDate(license.expiryDate)}
</Text>
</Box>
<Tooltip label="Download certificate">
<ActionIcon
variant="light"
radius="md"
size="lg"
loading={isDownloading}
onClick={onDownload}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Card>
);
}
function QuickAction({
icon: ActionIconCmp,
color,
label,
sub,
onClick,
}: {
icon: Icon;
color: string;
label: string;
sub: string;
onClick: () => void;
}) {
function EmptyCard({ message }: { message: string }) {
return (
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
<ActionIconCmp size={20} />
<Card withBorder radius="md" padding="xl">
<Center>
<Stack gap={6} align="center">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconFileText size={18} />
</ThemeIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
</div>
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
</Group>
</Card>
</UnstyledButton>
<Text size="sm" c="dimmed" ta="center">
{message}
</Text>
</Stack>
</Center>
</Card>
);
}
export default DashboardPage;

View File

@@ -1,487 +1,21 @@
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="My documents"
description="A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application."
/>
</Container>
);
}
export default DocumentVaultPage;

View File

@@ -1,438 +1,21 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { CountrySelect, getCountryName } from '@ema-platform/ui';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// 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<string | null>(null);
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 />
<CountrySelect label="Issuing Country" value={country} onChange={setCountry} 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 />
<AmharicDatePicker label="Issue Date" value={issueDate} onChange={setIssueDate} required />
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} 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', getCountryName(country, 'en')],
['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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function EndorsementPage() {
const navigate = useNavigate();
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
if (applying) {
return (
<Stack gap="md">
<Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
<div>
<Title order={3}>Apply for Endorsement</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag State Endorsement of Foreign CoC</Text>
</div>
</Group>
<ApplicationWizard onDone={() => setApplying(false)} />
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<div>
<Title order={3}>Endorsements</Title>
<Text fz="sm" c="dimmed">STCW Reg I/10 Flag-state endorsement of foreign-issued Certificates of Competency</Text>
</div>
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
Apply for Endorsement
</Button>
</Group>
{/* Info panel */}
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="md" wrap="nowrap">
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Text fw={700} fz="sm">What is an Endorsement?</Text>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
{[
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 1015 working days after all documents are verified.' },
].map(({ icon: Icon, color, title, desc }) => (
<Card key={title} withBorder radius="md" p="sm">
<Group gap="xs" mb={4}>
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
<Text fz="xs" fw={700}>{title}</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Group>
</Paper>
{/* Active applications */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsement Applications</Text>
{MOCK_ENDORSEMENTS.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No active endorsement applications.
</Alert>
) : (
<Stack gap="sm">
{MOCK_ENDORSEMENTS.map((app) => (
<Paper key={app.id} withBorder radius="md" p="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fz="sm" fw={700}>{app.cocType}</Text>
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}</Text>
</div>
</Group>
<Group gap="xs">
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
</Group>
</Group>
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
<Text fz="xs">{app.statusNote}</Text>
</Alert>
</Paper>
))}
</Stack>
)}
</Paper>
{/* Issued endorsements */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">My Endorsements</Text>
{MOCK_ISSUED.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
No endorsements issued yet.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_ISSUED.map((end) => (
<Card key={end.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">{end.cocType}</Text>
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
</div>
</Group>
<Badge color={end.statusColor} variant="light">{end.status}</Badge>
</Group>
<Divider mb="sm" />
<SimpleGrid cols={2} spacing="xs" mb="sm">
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
</SimpleGrid>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Paper>
{/* Preview modal */}
<Modal
opened={!!previewId}
onClose={() => setPreviewId(null)}
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
size="xl"
radius="lg"
>
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Endorsements"
description="Endorsements are not connected to the backend yet."
/>
</Container>
);
}
export default EndorsementPage;

View File

@@ -1,434 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconTruck,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Company Information' },
{ label: 'Bank Letter' },
{ label: 'Vehicle & Office' },
{ label: 'Employees' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
accept?: string;
}
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
{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',
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), minWidth: rem(24),
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>
);
}
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'ceoCv', label: 'CEO CV & Work Agreement', description: 'CEO / General Manager CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'adminCv', label: 'Administrative Staff CV & Work Agreement', description: 'Administrative staff CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
];
export function FreightForwarderLicenseApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
const [companyName, setCompanyName] = useState('');
const [tradeName, setTradeName] = useState('');
const [tinNumber, setTinNumber] = useState('');
const [commercialRegNumber, setCommercialRegNumber] = useState('');
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
const [businessAddress, setBusinessAddress] = useState('');
const [officeAddress, setOfficeAddress] = useState('');
const [applicantType, setApplicantType] = useState<string | null>(null);
const [ownershipType, setOwnershipType] = useState<string | null>(null);
// Step 1 — Bank Letter
const [bankName, setBankName] = useState('');
const [accountHolderName, setAccountHolderName] = useState('');
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
// Step 2 — Vehicle & Office
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
const [plateNumber, setPlateNumber] = useState('');
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
// Step 3 — Employees
const [ceoName, setCeoName] = useState('');
const [adminStaffName, setAdminStaffName] = useState('');
const [transit1Name, setTransit1Name] = useState('');
const [transit2Name, setTransit2Name] = useState('');
// Step 4 — Documents
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return (
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
);
if (active === 1) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000;
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership;
if (active === 3) return !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
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 submitTrigger({
url: '/logistics-licenses/freight-forwarder',
method: 'POST',
body: {
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
businessAddress, officeAddress, applicantType, ownershipType,
bankName, accountHolderName, capitalAmount,
vehicleOwnership, plateNumber, officeOwnership,
ceoName, adminStaffName, transit1Name, transit2Name,
},
}).unwrap();
notify.success('Freight Forwarder License application submitted successfully!');
navigate('/freight-forwarder-license');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Freight Forwarder License Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<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>
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Your account email and phone number will be used automatically no need to re-enter them here.
</Alert>
<SectionHead title="Company Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Bank letter must show a minimum capital of 1,500,000 ETB.
</Alert>
<SectionHead title="Bank Letter / Capital Evidence" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
<NumberInput
label="Capital Amount (ETB)"
required
min={0}
value={capitalAmount}
onChange={setCapitalAmount}
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
/>
</SimpleGrid>
</Stack>
)}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Vehicle Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Office Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
</SimpleGrid>
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
At least two qualified transit/customs employees certified by the Ethiopian Revenue Bureau are required.
</Alert>
<SectionHead title="Employee Profiles" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{active === 5 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Company Name" value={companyName} />
<ReviewRow label="Trade Name" value={tradeName} />
<ReviewRow label="TIN Number" value={tinNumber} />
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
<ReviewRow label="Business Address" value={businessAddress} />
<ReviewRow label="Office Address" value={officeAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Bank Name" value={bankName} />
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vehicle, Office & Employees</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
<ReviewRow label="CEO" value={ceoName} />
<ReviewRow label="Administrative Staff" value={adminStaffName} />
<ReviewRow label="Transit Employee 1" value={transit1Name} />
<ReviewRow label="Transit Employee 2" value={transit2Name} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/freight-forwarder-license') : prev}>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
) : (
<Button color="teal" leftSection={<IconTruck size={16} />} loading={submitting} onClick={handleSubmit}>
Submit Application
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,215 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconCertificate,
IconCheck,
IconCircleCheck,
IconClockHour4,
IconDownload,
IconInfoCircle,
IconTruck,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
interface FreightForwarderApplication {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
};
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
export function FreightForwarderLicensePage() {
const navigate = useNavigate();
const [application, setApplication] = useState<FreightForwarderApplication | null>(null);
const [fetchTrigger] = useApiMutation<FreightForwarderApplication>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/freight-forwarder/my', method: 'GET' })
.unwrap()
.then((data) => setApplication(data))
.catch(() => {/* no application yet */});
}, [fetchTrigger]);
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
<div>
<Title order={3}>Freight Forwarder License</Title>
<Text fz="sm" c="dimmed">Apply for and manage your Freight Forwarder License</Text>
</div>
</Group>
{!application && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconTruck size={28} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Apply for a Freight Forwarder License</Text>
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, and employee information</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
<RequirementItem label="Office title deed or office rental agreement" />
<RequirementItem label="CEO and administrative staff CVs and work agreements" />
<RequirementItem label="Two qualified transit/customs employees (ERB certified)" />
<RequirementItem label="Passport-size photo for certificate printing" />
</Stack>
<Button size="md" leftSection={<IconTruck size={18} />} onClick={() => navigate('/freight-forwarder-license/apply')}>
Start Application
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Freight Forwarder Licensing</Text>
</Group>
<Text fz="sm" c="dimmed">
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
The certificate is valid for <strong>one year</strong> and must be renewed annually.
</Text>
</Paper>
</>
)}
{application && (
<>
{application.status === 'Resubmit Required' && (
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
{application.remarks || 'Please correct the requested information and resubmit.'}
</Alert>
)}
{application.status === 'Rejected' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
{application.remarks || 'Your application was rejected.'}
</Alert>
)}
{application.status === 'Payment Pending' && (
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
Your application has been approved. Please pay 1000 ETB to receive your certificate.
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
</Alert>
)}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconTruck size={22} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{application.companyName}</Text>
<Text fz="xs" c="dimmed">{application.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
</Group>
{application.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{application.remarks}</Text>
</>
)}
</Paper>
{application.status !== 'Certificate Issued' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{application.status === 'Certificate Issued' && (
<div>
<Group gap="xs" mb="sm">
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">Issued Certificate</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your Freight Forwarder License certificate is ready. Valid until {application.expiryDate}.
</Alert>
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
<div>
<Text fw={600} fz="sm">Freight Forwarder License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,144 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconTruck,
} from '@tabler/icons-react';
import { FileButton } from '@mantine/core';
import { notify } from '@ema-platform/ui';
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
}
const RENEWAL_DOCS: DocSlot[] = [
{ key: 'prevCertificate', label: 'Previous Freight Forwarder Certificate', description: 'Your current/expiring certificate', required: true },
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months', required: true },
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
];
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
export function FreightForwarderLicenseRenewalPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [submitting, setSubmitting] = useState(false);
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({ url: '/logistics-licenses/freight-forwarder/renew', method: 'POST', body: {} }).unwrap();
notify.success('Renewal request submitted successfully!');
navigate('/freight-forwarder-license');
} catch {
notify.error('Renewal submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
Back
</Button>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
<div>
<Title order={3}>Renew Freight Forwarder License</Title>
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
</div>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
<Stack gap="md">
{RENEWAL_DOCS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</Stack>
<Group justify="flex-end" mt="xl">
<Button
color="teal"
leftSection={<IconCheck size={16} />}
loading={submitting}
disabled={!canSubmit}
onClick={handleSubmit}
>
Submit Renewal Request
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,550 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconBuildingBank,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Company Information' },
{ label: 'JV / Ownership Information' },
{ label: 'Organizational Experience' },
{ label: 'Transit Professionals' },
{ label: 'Logistics Capacity' },
{ label: 'Capital Information' },
{ label: 'Business License & Registration' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
accept?: string;
}
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
{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',
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), minWidth: rem(24),
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>
);
}
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'applicationLetter', label: 'Application Letter / Online Form', description: 'Signed application letter or completed online form', required: true, icon: IconFileDescription },
{ key: 'establishmentDoc', label: 'Company Establishment Document', description: 'Company establishment / formation document', required: true, icon: IconId },
{ key: 'memorandum', label: 'Memorandum / Articles of Association / Bylaw', description: 'Memorandum, articles of association, or bylaw document', required: true, icon: IconId },
{ key: 'orgProfile', label: 'Organizational Profile', description: 'Organizational profile document', required: true, icon: IconFileDescription },
{ key: 'renewedBusinessLicense', label: 'Renewed Business License', description: 'Current renewed business license', required: true, icon: IconId },
{ key: 'commercialRegCert', label: 'Commercial Registration Certificate', description: 'Commercial registration certificate', required: true, icon: IconId },
{ key: 'sectorEvidence', label: 'Evidence Company Is Active in Sector', description: 'Evidence the company is active in the sector', required: true, icon: IconFileDescription },
{ key: 'transitTrainingCert', label: 'Customs Transit Training Certificate', description: 'Training certificate covering all transit professionals', required: true, icon: IconShieldCheck },
{ key: 'employmentContracts', label: 'Employment Contracts — 3 Transit Professionals', description: 'Employment contracts for all three transit professionals', required: true, icon: IconFileDescription },
{ key: 'educationEvidence', label: 'Education Evidence', description: 'Education evidence for transit professionals', required: true, icon: IconFileDescription },
{ key: 'workExperienceEvidence', label: 'Work Experience Evidence', description: 'Work experience evidence for transit professionals', required: true, icon: IconFileDescription },
{ key: 'payrollEvidence', label: 'Three-Month Payroll Evidence', description: 'Payroll evidence for the last three months', required: true, icon: IconFileDescription },
{ key: 'taxPaymentEvidence', label: 'Monthly Tax Payment Evidence', description: 'Monthly tax payment evidence', required: true, icon: IconFileDescription },
{ key: 'facilityEvidence', label: 'Vehicle / Machinery / Warehouse Evidence', description: 'Evidence of vehicle, machinery, or warehouse capacity', required: true, icon: IconId },
{ key: 'vehicleLibre', label: 'Vehicle Libre / Registration Copy', description: 'Vehicle libre or registration copy', required: true, icon: IconId },
{ key: 'rentAgreement', label: 'Legal House / Office Rent Agreement', description: 'Legal house or office rent agreement (or ownership evidence)', required: true, icon: IconId },
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Bank confirmation letter for capital', required: true, icon: IconFileDescription },
{ key: 'capitalBalanceEvidence', label: 'Capital Balance Evidence', description: 'Evidence of capital balance', required: true, icon: IconFileDescription },
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for future certificate/ID issuance', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
];
export function JointInvestmentLicenseApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
const [companyName, setCompanyName] = useState('');
const [tinNumber, setTinNumber] = useState('');
const [commercialRegNumber, setCommercialRegNumber] = useState('');
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
const [businessAddress, setBusinessAddress] = useState('');
const [officeAddress, setOfficeAddress] = useState('');
const [applicantType, setApplicantType] = useState<string | null>(null);
const [ownershipType, setOwnershipType] = useState<string | null>(null);
const [contactPersonName, setContactPersonName] = useState('');
// Step 1 — JV / Ownership Information
const [jvType, setJvType] = useState<string | null>(null);
const [shareholderName, setShareholderName] = useState('');
const [shareholderNationality, setShareholderNationality] = useState('');
const [sharePercentage, setSharePercentage] = useState<string | number>('');
const [capitalContribution, setCapitalContribution] = useState<string | number>('');
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
const [beneficialOwnershipDeclaration, setBeneficialOwnershipDeclaration] = useState('');
// Step 2 — Organizational Experience Information
const [organizationalProfile, setOrganizationalProfile] = useState('');
const [pastWorkExperienceDescription, setPastWorkExperienceDescription] = useState('');
// Step 3 — Transit Professional Information
const [transiter1Name, setTransiter1Name] = useState('');
const [transiter1Position, setTransiter1Position] = useState('');
const [transiter2Name, setTransiter2Name] = useState('');
const [transiter2Position, setTransiter2Position] = useState('');
const [transiter3Name, setTransiter3Name] = useState('');
const [transiter3Position, setTransiter3Position] = useState('');
// Step 4 — Logistics Capacity Information
const [vehicleInfo, setVehicleInfo] = useState('');
const [machineryInfo, setMachineryInfo] = useState('');
const [warehouseInfo, setWarehouseInfo] = useState('');
const [cargoFacilityInfo, setCargoFacilityInfo] = useState('');
const [logisticsFacilityOwnershipType, setLogisticsFacilityOwnershipType] = useState<string | null>(null);
// Step 5 — Capital Information
const [bankName, setBankName] = useState('');
const [accountHolderName, setAccountHolderName] = useState('');
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
const [totalCapitalAmount, setTotalCapitalAmount] = useState<string | number>('');
// Step 6 — Business License and Registration Information
const [budgetYear, setBudgetYear] = useState('');
const [licenseValidityDate, setLicenseValidityDate] = useState('');
// Step 7 — Documents
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return (
!!companyName.trim() && !!tinNumber.trim() &&
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
);
if (active === 1) return (
!!jvType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
!!sharePercentage && !!capitalContribution && !!beneficialOwnershipDeclaration.trim()
);
if (active === 2) return !!organizationalProfile.trim() && !!pastWorkExperienceDescription.trim();
if (active === 3) return (
!!transiter1Name.trim() && !!transiter1Position.trim() &&
!!transiter2Name.trim() && !!transiter2Position.trim() &&
!!transiter3Name.trim() && !!transiter3Position.trim()
);
if (active === 4) return !!vehicleInfo.trim() && !!logisticsFacilityOwnershipType;
if (active === 5) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && !!totalCapitalAmount;
if (active === 6) return !!budgetYear.trim() && !!licenseValidityDate.trim();
if (active === 7) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
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 submitTrigger({
url: '/logistics-licenses/joint-investment',
method: 'POST',
body: {
companyName, tinNumber, commercialRegNumber, businessLicenseNumber,
businessAddress, officeAddress, applicantType, ownershipType, contactPersonName,
jvType, shareholderName, shareholderNationality, sharePercentage, capitalContribution,
investmentPermitNumber, beneficialOwnershipDeclaration,
organizationalProfile, pastWorkExperienceDescription,
transiter1Name, transiter1Position, transiter2Name, transiter2Position, transiter3Name, transiter3Position,
vehicleInfo, machineryInfo, warehouseInfo, cargoFacilityInfo, logisticsFacilityOwnershipType,
bankName, accountHolderName, capitalAmount, totalCapitalAmount,
budgetYear, licenseValidityDate,
},
}).unwrap();
notify.success('Joint Investment / JV Business License application submitted successfully!');
navigate('/joint-investment-license');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Joint Investment / JV Business License Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<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>
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Your account email and phone number will be used automatically no need to re-enter them here.
</Alert>
<SectionHead title="Company Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Company / Trade Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
<TextInput label="Contact Person Name (optional)" value={contactPersonName} onChange={(e) => setContactPersonName(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Provide details of the joint venture / joint investment ownership structure.
</Alert>
<SectionHead title="JV / Ownership Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
<Select label="JV / Joint Investment Type" required data={['Local-Foreign JV', 'Foreign-Foreign JV', 'Local-Local JV']} value={jvType} onChange={setJvType} />
<TextInput label="Shareholder / Partner Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
<TextInput label="Shareholder / Partner Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
<NumberInput label="Capital Contribution (ETB)" required min={0} value={capitalContribution} onChange={setCapitalContribution} />
<TextInput label="Investment Permit Number (if applicable)" value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
</SimpleGrid>
<Textarea
label="Beneficial Ownership Declaration"
required
placeholder="Describe the beneficial owners of the company..."
value={beneficialOwnershipDeclaration}
onChange={(e) => setBeneficialOwnershipDeclaration(e.currentTarget.value)}
rows={3}
/>
</Stack>
)}
{active === 2 && (
<Stack gap="md">
<SectionHead title="Organizational Experience Information" />
<Textarea
label="Organizational Profile"
required
placeholder="Describe the organization's structure, history, and prior work..."
value={organizationalProfile}
onChange={(e) => setOrganizationalProfile(e.currentTarget.value)}
rows={4}
/>
<Textarea
label="Past Work Experience Description"
required
placeholder="Describe relevant past work experience..."
value={pastWorkExperienceDescription}
onChange={(e) => setPastWorkExperienceDescription(e.currentTarget.value)}
rows={4}
/>
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
At least three transit professionals/transiters are required.
</Alert>
<SectionHead title="Transit Professional 1" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Full Name" required value={transiter1Name} onChange={(e) => setTransiter1Name(e.currentTarget.value)} />
<TextInput label="Position" required value={transiter1Position} onChange={(e) => setTransiter1Position(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Transit Professional 2" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Full Name" required value={transiter2Name} onChange={(e) => setTransiter2Name(e.currentTarget.value)} />
<TextInput label="Position" required value={transiter2Position} onChange={(e) => setTransiter2Position(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Transit Professional 3" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Full Name" required value={transiter3Name} onChange={(e) => setTransiter3Name(e.currentTarget.value)} />
<TextInput label="Position" required value={transiter3Position} onChange={(e) => setTransiter3Position(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 4 && (
<Stack gap="md">
<SectionHead title="Logistics Capacity Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Vehicle Information" required value={vehicleInfo} onChange={(e) => setVehicleInfo(e.currentTarget.value)} />
<TextInput label="Machinery Information (if applicable)" value={machineryInfo} onChange={(e) => setMachineryInfo(e.currentTarget.value)} />
<TextInput label="Warehouse Information (if applicable)" value={warehouseInfo} onChange={(e) => setWarehouseInfo(e.currentTarget.value)} />
<TextInput label="Cargo Receiving Facility Information (if applicable)" value={cargoFacilityInfo} onChange={(e) => setCargoFacilityInfo(e.currentTarget.value)} />
<Select label="Logistics Facility Ownership Type" required data={['Owned', 'Rented']} value={logisticsFacilityOwnershipType} onChange={setLogisticsFacilityOwnershipType} />
</SimpleGrid>
</Stack>
)}
{active === 5 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
The capital amount is subject to a minimum threshold configured by EMA. No fixed threshold is enforced here.
</Alert>
<SectionHead title="Capital Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
<NumberInput label="Capital Amount (ETB)" required min={0} value={capitalAmount} onChange={setCapitalAmount} />
<NumberInput label="Total Capital Amount (ETB)" required min={0} value={totalCapitalAmount} onChange={setTotalCapitalAmount} />
</SimpleGrid>
</Stack>
)}
{active === 6 && (
<Stack gap="md">
<SectionHead title="Business License and Registration Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Budget Year" required value={budgetYear} onChange={(e) => setBudgetYear(e.currentTarget.value)} />
<AmharicDatePicker label="License Validity Date" required value={licenseValidityDate} onChange={setLicenseValidityDate} />
</SimpleGrid>
</Stack>
)}
{active === 7 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{active === 8 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Company Name" value={companyName} />
<ReviewRow label="TIN Number" value={tinNumber} />
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
<ReviewRow label="Business Address" value={businessAddress} />
<ReviewRow label="Office Address" value={officeAddress} />
<ReviewRow label="Contact Person" value={contactPersonName} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">JV / Ownership Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="JV Type" value={jvType ?? ''} />
<ReviewRow label="Shareholder / Partner" value={shareholderName} />
<ReviewRow label="Nationality" value={shareholderNationality} />
<ReviewRow label="Share Percentage" value={sharePercentage ? `${sharePercentage}%` : ''} />
<ReviewRow label="Capital Contribution" value={capitalContribution ? `${Number(capitalContribution).toLocaleString()} ETB` : ''} />
<ReviewRow label="Investment Permit No." value={investmentPermitNumber} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Transit Professionals</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Transit Professional 1" value={`${transiter1Name}${transiter1Position}`} />
<ReviewRow label="Transit Professional 2" value={`${transiter2Name}${transiter2Position}`} />
<ReviewRow label="Transit Professional 3" value={`${transiter3Name}${transiter3Position}`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Capital Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Bank Name" value={bankName} />
<ReviewRow label="Capital Amount" value={capitalAmount ? `${Number(capitalAmount).toLocaleString()} ETB` : ''} />
<ReviewRow label="Total Capital Amount" value={totalCapitalAmount ? `${Number(totalCapitalAmount).toLocaleString()} ETB` : ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/joint-investment-license') : prev}>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
) : (
<Button color="teal" leftSection={<IconBuildingBank size={16} />} loading={submitting} onClick={handleSubmit}>
Submit Application
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,186 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Divider,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconCheck,
IconClockHour4,
IconInfoCircle,
IconBuildingBank,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Completed';
interface JointInvestmentApplication {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
Completed: 'green',
};
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
export function JointInvestmentLicensePage() {
const navigate = useNavigate();
const [application, setApplication] = useState<JointInvestmentApplication | null>(null);
const [fetchTrigger] = useApiMutation<JointInvestmentApplication>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/joint-investment/my', method: 'GET' })
.unwrap()
.then((data) => setApplication(data))
.catch(() => {/* no application yet */});
}, [fetchTrigger]);
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
<div>
<Title order={3}>Joint Investment / JV Business License</Title>
<Text fz="sm" c="dimmed">Apply for and manage your Joint Investment / JV Business License</Text>
</div>
</Group>
{!application && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconBuildingBank size={28} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Apply for a Joint Investment / JV Business License</Text>
<Text fz="sm" c="dimmed">Provide company, JV/ownership, organizational, transit, logistics, and capital information</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Company establishment and JV/ownership documentation" />
<RequirementItem label="Organizational profile and past work experience evidence" />
<RequirementItem label="At least three qualified transit professionals" />
<RequirementItem label="Vehicle, machinery, or warehouse evidence as applicable" />
<RequirementItem label="Bank confirmation letter and capital evidence" />
<RequirementItem label="Passport-size photo for future certificate issuance" />
</Stack>
<Button size="md" leftSection={<IconBuildingBank size={18} />} onClick={() => navigate('/joint-investment-license/apply')}>
Start Application
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Joint Investment / JV Business Licensing</Text>
</Group>
<Text fz="sm" c="dimmed">
This is a one-time evaluation license. Joint Investment / JV Business applications do not
require annual renewal once a decision is reached.
</Text>
</Paper>
</>
)}
{application && (
<>
{application.status === 'Resubmit Required' && (
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
{application.remarks || 'Please correct the requested information and resubmit.'}
</Alert>
)}
{application.status === 'Rejected' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
{application.remarks || 'Your application was rejected.'}
</Alert>
)}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconBuildingBank size={22} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{application.companyName}</Text>
<Text fz="xs" c="dimmed">{application.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
</Group>
{application.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{application.remarks}</Text>
</>
)}
</Paper>
{application.status !== 'Completed' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
{ label: 'Approved', done: ['Approved', 'Completed'].includes(application.status) },
{ label: 'Completed', done: (application.status as LicenseStatus) === 'Completed' },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{application.status === 'Completed' && (
<Alert color="teal" icon={<IconCheck size={17} />}>
Your Joint Investment / JV Business License application is complete. The decision and audit
history have been recorded.
</Alert>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,36 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Alert, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconArrowLeft, IconInfoCircle, IconBuildingBank } from '@tabler/icons-react';
export function JointInvestmentLicenseRenewalPage() {
const navigate = useNavigate();
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
Back
</Button>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
<div>
<Title order={3}>Joint Investment / JV Business License Renewal</Title>
<Text fz="sm" c="dimmed">Renewal information for your Joint Investment / JV Business License</Text>
</div>
</Group>
<Paper withBorder radius="lg" p="xl">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} title="Renewal Not Applicable">
The Joint Investment / JV Business License is issued as a one-time evaluation and does not
require annual renewal. Once your application is approved and completed, no further renewal
action is needed for this license type.
</Alert>
<Button mt="lg" leftSection={<IconArrowLeft size={16} />} onClick={() => navigate('/joint-investment-license')}>
Back to Joint Investment License
</Button>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,123 @@
import {
Checkbox,
Grid,
NumberInput,
Select,
Textarea,
TextInput,
} from '@mantine/core';
import {
conditionHolds,
localized,
type FormSectionConfig,
} from '@ema-platform/api';
interface Props {
section: FormSectionConfig;
values: Record<string, unknown>;
formData: Record<string, Record<string, unknown>>;
onChange: (key: string, value: unknown) => void;
disabled?: boolean;
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
errors?: Record<string, string>;
}
/**
* Renders one form section from the license type's configuration.
*
* Nothing here is Freight-Forwarder specific — adding a license type or moving
* a field is a backend config change, which is the whole point of the
* config-driven design.
*/
export function ConfigDrivenSection({
section,
values,
formData,
onChange,
disabled,
errors = {},
}: Props) {
const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
return (
<Grid>
{fields.map((field) => {
if (!conditionHolds(field.showWhen, formData)) return null;
const label = localized(field.label);
const value = values?.[field.key];
const error = errors[`${section.key}.${field.key}`];
const common = {
label,
description: localized(field.helpText) || undefined,
withAsterisk: field.required,
error,
// Read-only fields come from the user account and must not be edited.
disabled: disabled || field.readOnly,
};
const span = field.type === 'TEXTAREA' ? 12 : 6;
return (
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
{field.type === 'SELECT' ? (
<Select
{...common}
data={(field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label),
}))}
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
clearable={!field.required}
/>
) : field.type === 'BOOLEAN' ? (
<Checkbox
label={label}
error={error}
disabled={common.disabled}
checked={Boolean(value)}
onChange={(e) => onChange(field.key, e.currentTarget.checked)}
mt="md"
/>
) : field.type === 'NUMBER' || field.type === 'MONEY' ? (
<NumberInput
{...common}
value={(value as number) ?? ''}
onChange={(v) => onChange(field.key, v === '' ? null : Number(v))}
// Deliberately not clamped with min/max: Mantine would rewrite
// the entered figure on blur, quietly turning a capital of
// 900,000 into the 1,500,000 threshold. Validation reports the
// problem instead, leaving what the applicant typed intact.
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
/>
) : field.type === 'DATE' ? (
<TextInput
{...common}
type="date"
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
) : field.type === 'TEXTAREA' ? (
<Textarea
{...common}
autosize
minRows={3}
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
) : (
<TextInput
{...common}
type={field.type === 'EMAIL' ? 'email' : 'text'}
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
)}
</Grid.Col>
);
})}
</Grid>
);
}

View File

@@ -0,0 +1,183 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
FileButton,
Group,
Loader,
Stack,
Text,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCheck,
IconFileUpload,
IconTrash,
} from '@tabler/icons-react';
import {
conditionHolds,
localized,
uploadDocument,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
interface Props {
requirements: DocumentRequirement[];
attachments: Attachment[];
formData: Record<string, Record<string, unknown>>;
ownerType: 'APPLICATION' | 'APPLICATION_STAFF';
ownerId: string;
/** Document keys the officer flagged; these render as "needs fixing". */
flagged?: Record<string, string>;
/** When set, only flagged slots accept a new upload. */
restrictToFlagged?: boolean;
onUploaded: () => void;
readOnly?: boolean;
}
/**
* The upload slots for an application, driven by the configured document
* requirements. Conditional slots appear only when their rule matches the
* answers given — an owned vehicle asks for a libre, a rented one for the
* rental agreement.
*/
export function DocumentSlots({
requirements,
attachments,
formData,
ownerType,
ownerId,
flagged = {},
restrictToFlagged = false,
onUploaded,
readOnly,
}: Props) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const resetRefs = useRef<Record<string, () => void>>({});
const required = requirements.filter(
(r) =>
r.mode === 'ALWAYS' ||
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
);
async function handle(documentKey: string, file: File | null) {
if (!file) return;
setBusy(documentKey);
setError(null);
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
setBusy(null);
resetRefs.current[documentKey]?.();
if (result.ok) onUploaded();
else setError(result.error);
}
return (
<Stack gap="sm">
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{required.map((requirement) => {
const existing = attachments.find((a) => a.documentKey === requirement.key);
const uploaded = Boolean(existing?.files?.length);
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
return (
<Card
key={requirement.key}
withBorder
padding="md"
style={{
borderColor: flagRemark
? 'var(--mantine-color-orange-5)'
: uploaded
? 'var(--mantine-color-teal-4)'
: undefined,
borderStyle: uploaded ? 'solid' : 'dashed',
}}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{localized(requirement.name)}
</Text>
{requirement.mode === 'CONDITIONAL' && (
<Badge size="xs" variant="light" color="grape">
conditional
</Badge>
)}
{uploaded && !flagRemark && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded
</Badge>
)}
</Group>
{existing?.files?.[0] && (
<Text size="xs" c="dimmed" truncate>
{existing.files[0].originalName} ·{' '}
{(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
</Text>
)}
{flagRemark && (
<Text size="xs" c="orange.7" mt={4}>
Officer: {flagRemark}
</Text>
)}
</div>
<Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && (
<Button
size="xs"
variant="subtle"
component="a"
href={existing.files[0].url}
target="_blank"
>
View
</Button>
)}
{!locked && (
<FileButton
resetRef={(r) => {
if (r) resetRefs.current[requirement.key] = r;
}}
onChange={(file) => handle(requirement.key, file)}
accept={requirement.allowedMimeTypes?.join(',')}
>
{(props) => (
<Button
{...props}
size="xs"
variant={uploaded ? 'light' : 'filled'}
leftSection={
busy === requirement.key ? (
<Loader size={12} />
) : (
<IconFileUpload size={14} />
)
}
disabled={busy === requirement.key}
>
{uploaded ? 'Replace' : 'Upload'}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
</Card>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,229 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Box,
Button,
Card,
Center,
Group,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import {
IconArrowRight,
IconBuildingWarehouse,
IconChevronRight,
IconFileText,
IconShip,
IconTrendingUp,
} from '@tabler/icons-react';
import {
localized,
useGetLicenseCategoriesQuery,
useGetLicenseTypesQuery,
} from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
/**
* The licence catalogue an applicant chooses from, grouped by category.
*
* Shared by the dashboard and the applications page so the two never drift —
* they previously listed the same licence types in two different shapes.
*/
const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
};
function formatFee(amount: string | number | null, currency: string): string {
if (amount === null || amount === '') return 'No fee';
const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee';
return `${value.toLocaleString('en-US')} ${currency}`;
}
export function LicenseCatalogue() {
const navigate = useNavigate();
const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery();
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? []).filter((t) => t.isActive);
const catalogue = (categories?.items ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder);
const known = new Set(catalogue.map((c) => c.key));
return {
groups: catalogue
.map((category) => ({
category,
licenseTypes: active.filter((t) => t.category === category.key),
}))
.filter((g) => g.licenseTypes.length > 0),
// A licence type whose category has no catalogue entry would otherwise
// vanish from the page entirely.
orphans: active.filter((t) => !known.has(t.category)),
};
}, [types, categories]);
if (groups.length === 0 && orphans.length === 0) {
return (
<Card withBorder radius="md" padding="xl">
<Center>
<Stack gap={6} align="center">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconFileText size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
No licence types are available yet. Contact EMA if you were
expecting one.
</Text>
</Stack>
</Center>
</Card>
);
}
return (
<Stack gap="lg">
{groups.map(({ category, licenseTypes }) => (
<CategoryGroup
key={category.key}
icon={CATEGORY_ICONS[category.key] ?? IconFileText}
title={localized(category.name)}
description={localized(category.description)}
licenseTypes={licenseTypes}
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
/>
))}
{orphans.length > 0 && (
<CategoryGroup
icon={IconFileText}
title="Other licences"
description="Licence types that have not been assigned a category."
licenseTypes={orphans}
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
/>
)}
</Stack>
);
}
function CategoryGroup({
icon: Icon,
title,
description,
licenseTypes,
onSelect,
}: {
icon: typeof IconShip;
title: string;
description: string;
licenseTypes: LicenseType[];
onSelect: (type: LicenseType) => void;
}) {
return (
<Box>
<Group gap="xs" mb="xs">
<ThemeIcon variant="light" color="emaTeal" radius="md" size="md">
<Icon size={16} />
</ThemeIcon>
<Text fw={600} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{description}
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{licenseTypes.map((type) => (
<LicenseTypeCard key={type.id} type={type} onSelect={onSelect} />
))}
</SimpleGrid>
</Box>
);
}
function LicenseTypeCard({
type,
onSelect,
}: {
type: LicenseType;
onSelect: (type: LicenseType) => void;
}) {
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return (
<Card
withBorder
radius="md"
padding="md"
style={{ cursor: 'pointer', height: '100%' }}
onClick={() => onSelect(type)}
>
<Stack gap="xs" justify="space-between" h="100%">
<Box>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={600} size="sm" lh={1.35}>
{localized(type.name)}
</Text>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
</Group>
{type.description && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
{localized(type.description)}
</Text>
)}
</Box>
<Box>
<Group gap={6} mt="sm">
<Badge size="sm" variant="light" color="emaPrimary">
{formatFee(type.feeNewApplication, type.feeCurrency)}
</Badge>
{capital && (
<Tooltip label="Minimum capital that must be evidenced by a bank letter">
<Badge size="sm" variant="light" color="gray">
Capital {capital.toLocaleString('en-US')}
</Badge>
</Tooltip>
)}
{type.issuesCertificate ? (
<Badge size="sm" variant="light" color="teal">
{type.validityMonths} months
</Badge>
) : (
<Tooltip label="Concludes with an EMA decision rather than a certificate">
<Badge size="sm" variant="light" color="gray">
Evaluation only
</Badge>
</Tooltip>
)}
</Group>
<Button
fullWidth
mt="sm"
size="xs"
variant="light"
rightSection={<IconArrowRight size={14} />}
>
Start application
</Button>
</Box>
</Stack>
</Card>
);
}
export default LicenseCatalogue;

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import {
localized,
uploadDocument,
useGetAttachmentsQuery,
type StaffEvidenceRequirement,
} from '@ema-platform/api';
interface Props {
staffId: string;
evidence: StaffEvidenceRequirement[];
readOnly?: boolean;
onUploaded: () => void;
}
/**
* Per-person evidence uploads (CV, work agreement, ERB certificate).
*
* These attach to the staff member rather than the application, which is why
* staff are stored as real rows: each needs a stable owner for their files.
*/
export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props) {
const { data: attachments = [], refetch } = useGetAttachmentsQuery({
ownerType: 'APPLICATION_STAFF',
ownerId: staffId,
});
const [busy, setBusy] = useState<string | null>(null);
if (!evidence?.length) return null;
return (
<Group gap="xs">
{evidence.map((item) => {
const uploaded = attachments.some(
(a) => a.documentKey === item.docKey && a.files?.length,
);
return (
<FileButton
key={item.docKey}
accept="application/pdf,image/jpeg,image/png"
onChange={async (file) => {
if (!file) return;
setBusy(item.docKey);
await uploadDocument({
ownerType: 'APPLICATION_STAFF',
ownerId: staffId,
documentKey: item.docKey,
file,
});
setBusy(null);
refetch();
onUploaded();
}}
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant={uploaded ? 'light' : 'outline'}
color={uploaded ? 'teal' : item.mandatory ? 'blue' : 'gray'}
disabled={readOnly || busy === item.docKey}
leftSection={
busy === item.docKey ? (
<Loader size={10} />
) : uploaded ? (
<IconCheck size={12} />
) : undefined
}
>
{localized(item.label)}
{item.mandatory && !uploaded ? ' *' : ''}
</Button>
)}
</FileButton>
);
})}
</Group>
);
}

View File

@@ -0,0 +1,637 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Center,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Paper,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCheck,
IconInfoCircle,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
buildWizardSteps,
conditionHolds,
extractErrorMessage,
extractValidationIssues,
localized,
validateSections,
useAddStaffMutation,
useCreateApplicationMutation,
useGetApplicationQuery,
useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery,
usePatchSectionMutation,
useRemoveStaffMutation,
useResolveRemarkMutation,
useResubmitApplicationMutation,
useSubmitApplicationMutation,
type FieldErrors,
type ValidationIssue,
} from '@ema-platform/api';
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
import { DocumentSlots } from '../components/DocumentSlots';
import { StaffEvidence } from '../components/StaffEvidence';
/**
* The applicant wizard, rendered entirely from the license type's
* configuration. The same page serves every license type — the route's
* `typeCode` decides which configuration is loaded.
*/
export function LicenseApplicationPage() {
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
const navigate = useNavigate();
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
// Create (or resume) the draft up front, so uploads have a real owner to
// attach to and nothing is lost if the browser is closed mid-wizard.
useEffect(() => {
if (appId || !config) return;
createApplication({ licenseType: typeCode })
.unwrap()
.then((app) => setAppId(app.id))
.catch((err) =>
notifications.show({
color: 'red',
title: 'Could not start application',
message: extractErrorMessage(err),
}),
);
}, [appId, config, createApplication, typeCode]);
const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
skip: !appId,
});
const { data: attachments = [], refetch: refetchAttachments } =
useGetAttachmentsQuery(
{ ownerType: 'APPLICATION', ownerId: appId as string },
{ skip: !appId },
);
const [patchSection] = usePatchSectionMutation();
const [submitApplication, { isLoading: submitting }] = useSubmitApplicationMutation();
const [resubmitApplication, { isLoading: resubmitting }] = useResubmitApplicationMutation();
const [resolveRemark] = useResolveRemarkMutation();
const [addStaff] = useAddStaffMutation();
const [removeStaff] = useRemoveStaffMutation();
const [active, setActive] = useState(0);
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
const [issues, setIssues] = useState<ValidationIssue[]>([]);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [staffModal, setStaffModal] = useState<string | null>(null);
const [newStaff, setNewStaff] = useState({ fullName: '', position: '', yearsOfExperience: 0 });
// Seed local edits from the server copy once it arrives.
useEffect(() => {
if (detail?.application?.formData) setDraft(detail.application.formData);
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
const application = detail?.application;
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
const openRemarks = detail?.openRemarks ?? [];
const flaggedSections = useMemo(
() =>
Object.fromEntries(
openRemarks.filter((r) => r.targetType === 'FORM_SECTION').map((r) => [r.targetKey, r.remark]),
),
[openRemarks],
);
const flaggedDocuments = useMemo(
() =>
Object.fromEntries(
openRemarks.filter((r) => r.targetType === 'DOCUMENT').map((r) => [r.targetKey, r.remark]),
),
[openRemarks],
);
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
const steps = useMemo(
() => buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft),
[config, draft],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
[steps],
);
if (loadingConfig || !config || !appId || !application) {
return (
<Center h={400}>
<Loader />
</Center>
);
}
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
async function saveSection(sectionKey: string) {
// During an adjustment round only flagged sections are editable, so don't
// even attempt a write the server would reject.
if (isAdjusting && !flaggedSections[sectionKey]) return;
try {
await patchSection({
id: appId as string,
sectionKey,
values: draft[sectionKey] ?? {},
}).unwrap();
} catch (err) {
notifications.show({
color: 'red',
title: 'Could not save',
message: extractErrorMessage(err),
});
}
}
async function handleSubmit() {
setIssues([]);
if (!readOnly && currentStep?.sections?.length) {
const errors = validateSections(currentStep.sections, draft);
setFieldErrors(errors);
if (Object.keys(errors).length) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: 'Complete the highlighted fields before submitting.',
});
return;
}
}
for (const section of sections) await saveSection(section.key);
try {
if (isAdjusting) {
for (const remark of openRemarks) {
await resolveRemark({ id: appId as string, remarkId: remark.id }).unwrap();
}
await resubmitApplication(appId as string).unwrap();
notifications.show({
color: 'teal',
title: 'Resubmitted',
message: 'Your corrections were sent back to the reviewing officer.',
});
} else {
await submitApplication(appId as string).unwrap();
notifications.show({
color: 'teal',
title: 'Application submitted',
message: 'You will be notified as it progresses.',
});
}
navigate('/licensing/applications');
} catch (err) {
const found = extractValidationIssues(err);
setIssues(found);
notifications.show({
color: 'red',
title: 'Application incomplete',
message: found.length
? `${found.length} item(s) still need attention.`
: extractErrorMessage(err),
});
}
}
const currentStep = steps[active];
/**
* Checks the current step before moving on.
*
* The server rejects an incomplete application anyway, but only at submit —
* by then the applicant has walked through every step and has to hunt for
* what was missing. Validating per step points at the field directly.
*/
async function validateCurrentStep(): Promise<boolean> {
// The wizard does not render until the configuration has loaded, but this
// is declared above that guard, so narrow it here too.
if (!currentStep || !config) return true;
if (currentStep.kind === 'sections') {
const errors = validateSections(currentStep.sections, draft);
setFieldErrors(errors);
const count = Object.keys(errors).length;
if (count > 0) {
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
});
return false;
}
return true;
}
if (currentStep.kind === 'staff') {
const missing = config.staffRoleRequirements
.filter(
(role) =>
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey).length <
role.minCount,
)
.map((role) => `${localized(role.name)} (${role.minCount} required)`);
if (missing.length) {
notifications.show({
color: 'red',
title: 'Staff incomplete',
message: `Still needed: ${missing.join(', ')}.`,
});
return false;
}
return true;
}
if (currentStep.kind === 'documents') {
const supplied = new Set(
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
);
const missing = config.documentRequirements
.filter(
(req) =>
req.mode === 'ALWAYS' ||
(req.mode === 'CONDITIONAL' && conditionHolds(req.conditionExpression, draft)),
)
.filter((req) => !supplied.has(req.key))
.map((req) => localized(req.name));
if (missing.length) {
notifications.show({
color: 'red',
title: 'Documents missing',
message: `Upload: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` and ${missing.length - 3} more` : ''}.`,
});
return false;
}
return true;
}
return true;
}
async function handleContinue() {
// A locked step during an adjustment round has nothing to validate.
if (!readOnly && !(await validateCurrentStep())) return;
if (currentStep?.kind === 'sections') {
for (const section of currentStep.sections) await saveSection(section.key);
}
setFieldErrors({});
setActive((s) => Math.min(steps.length - 1, s + 1));
}
/** Going back is always allowed; going forward validates each step passed. */
async function goToStep(target: number) {
if (target <= active) {
setActive(target);
return;
}
if (!readOnly && !(await validateCurrentStep())) return;
if (currentStep?.kind === 'sections') {
for (const section of currentStep.sections) await saveSection(section.key);
}
setFieldErrors({});
setActive(active + 1);
}
return (
<Container size="lg" py="md">
<Group justify="space-between" mb="xs">
<div>
<Title order={3}>{localized(config.licenseType.name)}</Title>
<Text size="sm" c="dimmed">
{application.applicationNumber} ·{' '}
<Badge size="sm" variant="light">
{application.status.replace(/_/g, ' ')}
</Badge>
</Text>
</div>
<Text size="sm" c="dimmed">
Fee: {config.fee ?? '—'} {config.feeCurrency}
</Text>
</Group>
{isAdjusting && (
<Alert
color="orange"
icon={<IconAlertTriangle size={16} />}
title="Corrections requested"
mb="md"
>
<Stack gap={4}>
{openRemarks.map((remark) => (
<Text size="sm" key={remark.id}>
<b>{remark.targetKey}</b>: {remark.remark}
</Text>
))}
<Text size="xs" c="dimmed" mt={4}>
Only the items listed above can be changed.
</Text>
</Stack>
</Alert>
)}
{issues.length > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
<Stack gap={2}>
{issues.map((issue, i) => (
<Text size="sm" key={i}>
{issue.message}
</Text>
))}
</Stack>
</Alert>
)}
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{steps.map((step) => (
<Stepper.Step key={step.key} label={step.label} />
))}
</Stepper>
{currentStep?.kind === 'sections' && (
<Stack gap="lg">
{currentStep.sections.map((section, index) => {
const locked = isAdjusting && !flaggedSections[section.key];
return (
<div key={section.key}>
{index > 0 && <Divider mb="lg" />}
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
{locked && (
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
This section was accepted and is locked for this round.
</Alert>
)}
<ConfigDrivenSection
section={section}
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
disabled={readOnly || locked}
onChange={(key, value) => {
setDraft((prev) => ({
...prev,
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
}));
// Clear the error as soon as the applicant addresses it.
setFieldErrors((prev) => {
const next = { ...prev };
delete next[`${section.key}.${key}`];
return next;
});
}}
/>
</div>
);
})}
</Stack>
)}
{currentStep?.kind === 'staff' && (
<Stack>
{config.staffRoleRequirements.map((role) => {
const members = (detail?.staff ?? []).filter((s) => s.roleKey === role.roleKey);
return (
<Card withBorder key={role.roleKey} padding="md">
<Group justify="space-between" mb="xs">
<div>
<Text fw={600} size="sm">
{localized(role.name)}
</Text>
<Text size="xs" c="dimmed">
{members.length} of {role.minCount} required
{role.requiredEvidence.length > 0 &&
` · each needs ${role.requiredEvidence
.filter((e) => e.mandatory)
.map((e) => localized(e.label))
.join(', ')}`}
</Text>
</div>
<Group gap="xs">
{members.length >= role.minCount && (
<Badge color="teal" size="sm" leftSection={<IconCheck size={10} />}>
complete
</Badge>
)}
{!readOnly && (
<Button
size="xs"
variant="light"
leftSection={<IconPlus size={14} />}
onClick={() => setStaffModal(role.roleKey)}
>
Add
</Button>
)}
</Group>
</Group>
<Stack gap="xs">
{members.map((member) => (
<Card withBorder key={member.id} padding="sm" radius="sm">
<Group justify="space-between" mb={member.id ? 'xs' : 0}>
<div>
<Text size="sm" fw={500}>
{member.fullName}
</Text>
<Text size="xs" c="dimmed">
{member.position ?? '—'}
{member.yearsOfExperience
? ` · ${member.yearsOfExperience} yrs`
: ''}
</Text>
</div>
{!readOnly && (
<ActionIcon
variant="subtle"
color="red"
onClick={async () => {
await removeStaff({ id: appId, staffId: member.id });
refetch();
}}
>
<IconTrash size={16} />
</ActionIcon>
)}
</Group>
<StaffEvidence
staffId={member.id}
evidence={role.requiredEvidence}
readOnly={readOnly}
onUploaded={refetch}
/>
</Card>
))}
</Stack>
</Card>
);
})}
</Stack>
)}
{currentStep?.kind === 'documents' && (
<DocumentSlots
requirements={config.documentRequirements}
attachments={attachments}
formData={draft}
ownerType="APPLICATION"
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={isAdjusting}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();
refetch();
}}
/>
)}
{currentStep?.kind === 'review' && (
<Stack>
{currentStep.sections.map((section) => (
<div key={section.key}>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
{localized(section.title)}
</Text>
<ConfigDrivenSection
section={section}
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
disabled={readOnly}
onChange={(key, value) => {
setDraft((prev) => ({
...prev,
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
}));
setFieldErrors((prev) => {
const next = { ...prev };
delete next[`${section.key}.${key}`];
return next;
});
}}
/>
<Divider my="lg" />
</div>
))}
<Title order={5}>Review</Title>
{sections.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
{localized(section.title)}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{(section.fields ?? [])
.filter((f) => conditionHolds(f.showWhen, draft))
.map((field) => (
<Table.Tr key={field.key}>
<Table.Td w="45%">
<Text size="xs" c="dimmed">
{localized(field.label)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{String(draft[section.key]?.[field.key] ?? '—')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider my="sm" />
</div>
))}
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
onClick={() => setActive((s) => Math.max(0, s - 1))}
disabled={active === 0}
>
Back
</Button>
{active < steps.length - 1 ? (
<Button onClick={handleContinue}>Continue</Button>
) : (
<Button
color="teal"
loading={submitting || resubmitting}
disabled={readOnly}
onClick={handleSubmit}
>
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
</Button>
)}
</Group>
</Paper>
<Modal
opened={Boolean(staffModal)}
onClose={() => setStaffModal(null)}
title="Add staff member"
>
<Stack>
<TextInput
label="Full name"
withAsterisk
value={newStaff.fullName}
onChange={(e) => setNewStaff({ ...newStaff, fullName: e.currentTarget.value })}
/>
<TextInput
label="Position"
value={newStaff.position}
onChange={(e) => setNewStaff({ ...newStaff, position: e.currentTarget.value })}
/>
<NumberInput
label="Years of experience"
value={newStaff.yearsOfExperience}
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
min={0}
/>
<Button
onClick={async () => {
if (!newStaff.fullName.trim() || !staffModal) return;
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
setStaffModal(null);
refetch();
}}
>
Add
</Button>
</Stack>
</Modal>
</Container>
);
}
export default LicenseApplicationPage;

View File

@@ -0,0 +1,321 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
Title,
} from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import { LicenseCatalogue } from '../components/LicenseCatalogue';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
localized,
useBypassPaymentMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
} from '@ema-platform/api';
/**
* The applicant's landing page: which licences they can apply for, and the
* state of anything already filed.
*
* The licence types come from the backend, so a newly configured type appears
* here without a code change — and each one carries its own document
* requirements into the wizard.
*/
export function MyApplicationsPage() {
const navigate = useNavigate();
const { data, isLoading } = useGetMyApplicationsQuery();
const { pay, isPaying } = useApplicationPayment();
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const { data: licences } = useGetMyLicensesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
async function handleBypass(applicationId: string) {
try {
const result = await bypassPayment(applicationId).unwrap();
notifications.show({
color: 'teal',
title: 'Payment bypassed',
message: result.certificateIssued
? 'The licence has been issued — see My licences below.'
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
});
} catch (err) {
notifications.show({
color: 'red',
title: 'Bypass failed',
message: extractErrorMessage(err),
});
}
}
/**
* Opens the certificate belonging to an application.
*
* The applicant knows their application number, not the licence id, so the
* licence is looked up from the list already loaded rather than making them
* find it in a separate table.
*/
async function openCertificateForApplication(applicationId: string) {
const licence = (licences?.items ?? []).find(
(l) => l.applicationId === applicationId,
);
if (!licence) {
notifications.show({
color: 'yellow',
title: 'Certificate not ready',
message:
'The licence for this application has not been issued yet. It will appear under My licences.',
});
return;
}
await downloadCertificate(licence.id);
}
async function downloadCertificate(licenseId: string) {
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
window.open(url, '_blank', 'noopener');
} catch (err) {
notifications.show({
color: 'red',
title: 'Could not open the certificate',
message: extractErrorMessage(err),
});
}
}
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
const items = data?.items ?? [];
return (
<Container size="lg" py="md">
<Title order={3} mb="xs">
Licence applications
</Title>
<Text size="sm" c="dimmed" mb="md">
Choose a licence to apply for. Each one asks for its own forms and
supporting documents.
</Text>
<Box mb="xl">
<LicenseCatalogue />
</Box>
{(licences?.items ?? []).length > 0 && (
<>
<Title order={4} mb="sm">
My licences
</Title>
<Card withBorder padding={0} mb="xl">
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Certificate</Table.Th>
<Table.Th>Licence</Table.Th>
<Table.Th>Valid until</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(licences?.items ?? []).map((licence) => (
<Table.Tr key={licence.id}>
<Table.Td>
<Text size="sm" fw={500} ff="monospace">
{licence.certificateNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{localized(licence.licenseType?.name) || '—'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{new Date(licence.expiryDate).toLocaleDateString()}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={licence.status === 'ACTIVE' ? 'green' : 'gray'}
>
{licence.status}
</Badge>
</Table.Td>
<Table.Td align="right">
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={14} />}
onClick={() => downloadCertificate(licence.id)}
>
Certificate
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</>
)}
<Title order={4} mb="sm">
My applications
</Title>
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
<Card withBorder padding="sm" radius="md" mb="sm"
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
<Text size="sm">
Your payment has been received. The certificate is being prepared
and will appear under My licences above once it is issued.
</Text>
</Card>
)}
{items.length === 0 ? (
<Card withBorder padding="xl">
<Stack align="center" gap="xs">
<Text c="dimmed">You have not filed any applications yet.</Text>
<Text size="sm" c="dimmed">
Pick a licence above to get started.
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding={0}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((app) => (
<Table.Tr key={app.id}>
<Table.Td>
<Text size="sm" fw={500}>
{app.applicationNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{app.companyName ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[app.status]} variant="light">
{STATUS_LABELS[app.status]}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.submittedAt
? new Date(app.submittedAt).toLocaleDateString()
: '—'}
</Text>
</Table.Td>
<Table.Td align="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={() => handleBypass(app.id)}
title="Testing only — marks the fee paid and issues the licence"
>
Bypass payment
</Button>
)}
{/* An issued application's primary action is the
certificate. It used to be "View", which opened the
application wizard — so the one thing the applicant
came back for was the one thing the button did not do. */}
{app.status === 'CERTIFICATE_ISSUED' && (
<Button
size="xs"
leftSection={<IconDownload size={14} />}
onClick={() => openCertificateForApplication(app.id)}
>
Certificate
</Button>
)}
<Button
size="xs"
loading={isPaying && app.status === 'PAYMENT_PENDING'}
variant={
app.status === 'RESUBMIT_REQUIRED' ||
app.status === 'PAYMENT_PENDING'
? 'filled'
: 'subtle'
}
color={
app.status === 'RESUBMIT_REQUIRED'
? 'orange'
: app.status === 'PAYMENT_PENDING'
? 'yellow'
: undefined
}
onClick={() =>
// Paying leaves the SPA for Telebirr, so this is a
// provider hand-off rather than a route change.
app.status === 'PAYMENT_PENDING'
? pay(app.id)
: navigate(
`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`,
)
}
>
{app.status === 'DRAFT'
? 'Continue'
: app.status === 'RESUBMIT_REQUIRED'
? 'Fix & resubmit'
: app.status === 'PAYMENT_PENDING'
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
: app.status === 'CERTIFICATE_ISSUED'
? 'Application'
: 'View'}
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
</Container>
);
}
export default MyApplicationsPage;

View File

@@ -1,164 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconShieldOff,
IconShip,
IconStack2,
IconTruck,
IconUsers,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued' | 'Completed';
interface LicenseSummary {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green', Completed: 'green',
};
const LICENSE_LINES = [
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', apiPath: '/logistics-licenses/freight-forwarder/my', icon: IconTruck, color: 'blue' },
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', apiPath: '/logistics-licenses/shipping-agent/my', icon: IconShip, color: 'indigo' },
{ key: 'combined', label: 'Combined License', route: '/combined-license', apiPath: '/logistics-licenses/combined/my', icon: IconStack2, color: 'violet' },
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', apiPath: '/logistics-licenses/joint-investment/my', icon: IconUsers, color: 'grape' },
{ key: 'mto', label: 'MTO License', route: '/mto-license', apiPath: '/logistics-licenses/mto/my', icon: IconTruck, color: 'cyan' },
{ key: 'waiver', label: 'Waiver', route: '/waiver', apiPath: '/logistics-licenses/waiver/my', icon: IconShieldOff, color: 'orange' },
] as const;
const ACTIVE_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Resubmit Required', 'Payment Pending', 'Payment Confirmed']);
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
export function LogisticsDashboardPage() {
const navigate = useNavigate();
const [apps, setApps] = useState<Record<string, LicenseSummary | null>>({});
const [loading, setLoading] = useState(true);
const [fetchTrigger] = useApiMutation<LicenseSummary>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
Promise.allSettled(
LICENSE_LINES.map((line) => fetchTrigger({ url: line.apiPath, method: 'GET' }).unwrap())
).then((results) => {
const next: Record<string, LicenseSummary | null> = {};
results.forEach((r, i) => {
next[LICENSE_LINES[i].key] = r.status === 'fulfilled' ? r.value : null;
});
setApps(next);
setLoading(false);
});
}, [fetchTrigger]);
const values = Object.values(apps).filter((a): a is LicenseSummary => !!a);
const stats = {
total: values.length,
active: values.filter((a) => ACTIVE_STATUSES.has(a.status)).length,
issued: values.filter((a) => ISSUED_STATUSES.has(a.status)).length,
};
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
<IconStack2 size={24} />
</ThemeIcon>
<div>
<Title order={3}>My Logistics Licenses Dashboard</Title>
<Text fz="sm" c="dimmed">Track your freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications</Text>
</div>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{loading ? (
<>
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
<Skeleton height={80} radius="md" />
</>
) : (
<>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Applications Filed</Text>
<Text fz="xl" fw={700} c="indigo.6" mt={4}>{stats.total}</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Active / In Progress</Text>
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.active}</Text>
</Card>
<Card withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificates Issued</Text>
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.issued}</Text>
</Card>
</>
)}
</SimpleGrid>
<Paper withBorder radius="lg" p="lg">
<Text fw={700} mb="md">License Applications</Text>
<Stack gap={0}>
{LICENSE_LINES.map((line, i) => {
const app = apps[line.key];
return (
<Group
key={line.key}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom: i < LICENSE_LINES.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
cursor: 'pointer',
}}
onClick={() => navigate(app ? line.route : `${line.route}/apply`)}
>
<Group gap="sm">
<ThemeIcon size={36} radius="md" color={line.color} variant="light">
<line.icon size={18} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>{line.label}</Text>
<Text size="xs" c="dimmed">{app ? app.id : 'Not applied yet'}</Text>
</div>
</Group>
{loading ? (
<Skeleton height={22} width={90} radius="xl" />
) : app ? (
<Badge variant="light" color={STATUS_COLOR[app.status] ?? 'gray'} radius="sm">
{app.status}
</Badge>
) : (
<Button size="xs" variant="light">Apply</Button>
)}
</Group>
);
})}
</Stack>
</Paper>
</Stack>
);
}

View File

@@ -1,334 +1,21 @@
import { useRef, useState } from 'react';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
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';
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
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">
<AmharicDatePicker
label="Issue Date"
value={issuedDate}
onChange={setIssuedDate}
size="sm"
/>
<AmharicDatePicker
label="Expiry Date"
value={expiryDate}
onChange={setExpiryDate}
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>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Medical certificate"
description="Medical certificates are not connected to the backend yet."
/>
</Container>
);
}
export default MedicalCertificatePage;

View File

@@ -1,706 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
import {
Alert,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconBuildingWarehouse,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
IconTruck,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Company Information' },
{ label: 'Ownership Information' },
{ label: 'Management & Board' },
{ label: 'Employee Information' },
{ label: 'Terminal & Facility' },
{ label: 'Fleet & Equipment' },
{ label: 'Financial Capacity' },
{ label: 'Insurance, Bond & Liability' },
{ label: 'Agent Network & ICT' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
accept?: string;
}
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
{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',
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), minWidth: rem(24),
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>
);
}
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
{ key: 'businessLicense', label: 'Business License / Trade Registration', description: 'Valid business license', required: true, icon: IconId },
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
{ key: 'memorandum', label: 'Memorandum and Articles of Association', description: 'Company formation documents', required: true, icon: IconFileDescription },
{ key: 'investmentPermit', label: 'Investment Permit', description: 'Required for foreign or joint venture ownership', required: false, icon: IconFileDescription },
{ key: 'capitalProof', label: 'Paid-up Capital Proof', description: 'Evidence of paid-up capital', required: true, icon: IconFileDescription },
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Letter confirming capital deposit', required: true, icon: IconFileDescription },
{ key: 'boardCvs', label: 'Board Member CVs', description: 'CVs of all board members', required: true, icon: IconFileDescription },
{ key: 'managerCv', label: 'Manager CV and Qualification Documents', description: 'General Manager CV and qualifications', required: true, icon: IconFileDescription },
{ key: 'employeeQualDocs', label: 'Employee Qualification Documents', description: 'Qualification documents for key employees', required: true, icon: IconFileDescription },
{ key: 'terminalLease', label: 'Terminal Lease or Title Document', description: 'Terminal ownership or lease document', required: true, icon: IconBuildingWarehouse },
{ key: 'warehouseEvidence', label: 'Warehouse / Terminal Evidence', description: 'Photos or inspection evidence of warehouse/terminal', required: false, icon: IconBuildingWarehouse },
{ key: 'truckOwnership', label: 'Truck Ownership or Rental Agreement', description: 'Vehicle registration or rental contract', required: true, icon: IconTruck },
{ key: 'equipmentOwnership', label: 'Equipment Ownership or Rental Agreement', description: 'Required if equipment is rented', required: false, icon: IconTruck },
{ key: 'insuranceCert', label: 'Insurance Certificate', description: 'Cargo and multimodal transport liability insurance', required: true, icon: IconShieldCheck },
{ key: 'customsBond', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck },
{ key: 'agentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription },
{ key: 'branchContract', label: 'Branch Office Contract', description: 'Branch office lease or ownership contract', required: false, icon: IconFileDescription },
{ key: 'operationalManual', label: 'Administrative / Operational Manual', description: 'Operational procedures manual', required: true, icon: IconFileDescription },
{ key: 'ictEvidence', label: 'ICT Capability Evidence', description: 'Evidence of ICT / tracking system capability', required: true, icon: IconFileDescription },
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
];
export function MtoLicenseApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Company / Applicant Information (email/phone auto-fetched from account, not shown here)
const [companyName, setCompanyName] = useState('');
const [tradeName, setTradeName] = useState('');
const [tinNumber, setTinNumber] = useState('');
const [commercialRegNumber, setCommercialRegNumber] = useState('');
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
const [applicantType, setApplicantType] = useState<string | null>(null);
const [ownershipTypeCompany, setOwnershipTypeCompany] = useState<string | null>(null);
const [businessAddress, setBusinessAddress] = useState('');
const [headOfficeAddress, setHeadOfficeAddress] = useState('');
const [hasBranchOffices, setHasBranchOffices] = useState<string | null>(null);
const [branchOfficeAddress, setBranchOfficeAddress] = useState('');
// Step 1 — Ownership Information
const [ownershipType, setOwnershipType] = useState<string | null>(null);
const [shareholderName, setShareholderName] = useState('');
const [shareholderNationality, setShareholderNationality] = useState('');
const [sharePercentage, setSharePercentage] = useState<string | number>('');
const [investmentClassification, setInvestmentClassification] = useState<string | null>(null);
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
const [beneficialOwnershipDeclared, setBeneficialOwnershipDeclared] = useState(false);
// Step 2 — Management and Board Information
const [gmName, setGmName] = useState('');
const [gmQualification, setGmQualification] = useState('');
const [gmExperience, setGmExperience] = useState('');
const [boardMembers, setBoardMembers] = useState('');
const [gmCv, setGmCv] = useState<File | null>(null);
const [gmEducationCert, setGmEducationCert] = useState<File | null>(null);
const [gmExperienceEvidence, setGmExperienceEvidence] = useState<File | null>(null);
// Step 3 — Employee Information
const [empFullName, setEmpFullName] = useState('');
const [empPosition, setEmpPosition] = useState('');
const [empQualification, setEmpQualification] = useState('');
const [empExperience, setEmpExperience] = useState('');
const [empEmploymentType, setEmpEmploymentType] = useState<string | null>(null);
const [empProfCert, setEmpProfCert] = useState<File | null>(null);
const [empContract, setEmpContract] = useState<File | null>(null);
// Step 4 — Terminal and Facility Information
const [terminalLocation, setTerminalLocation] = useState('');
const [terminalSize, setTerminalSize] = useState('');
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
const [terminalLeaseDoc, setTerminalLeaseDoc] = useState<File | null>(null);
const [warehouseAvailability, setWarehouseAvailability] = useState<string | null>(null);
const [warehouseSize, setWarehouseSize] = useState('');
const [terminalSecurityInfo, setTerminalSecurityInfo] = useState('');
const [terminalPhotos, setTerminalPhotos] = useState<File | null>(null);
// Step 5 — Fleet and Equipment Information
const [numberOfTrucks, setNumberOfTrucks] = useState<string | number>('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [truckCapacity, setTruckCapacity] = useState('');
const [truckOwnership, setTruckOwnership] = useState<string | null>(null);
const [vehicleRegDoc, setVehicleRegDoc] = useState<File | null>(null);
const [vehicleRentalContract, setVehicleRentalContract] = useState<File | null>(null);
const [cargoHandlingEquipment, setCargoHandlingEquipment] = useState('');
const [equipmentOwnership, setEquipmentOwnership] = useState<string | null>(null);
const [equipmentEvidence, setEquipmentEvidence] = useState<File | null>(null);
// Step 6 — Financial Capacity
const [paidUpCapitalAmount, setPaidUpCapitalAmount] = useState<string | number>('');
const [bankConfirmation, setBankConfirmation] = useState<File | null>(null);
const [bankDepositEvidence, setBankDepositEvidence] = useState<File | null>(null);
const [auditedFinancialStatement, setAuditedFinancialStatement] = useState<File | null>(null);
const [assetValuationEvidence, setAssetValuationEvidence] = useState<File | null>(null);
// Step 7 — Insurance, Bond, and Liability
const [cargoLiabilityInsurance, setCargoLiabilityInsurance] = useState<File | null>(null);
const [mtoLiabilityInsurance, setMtoLiabilityInsurance] = useState<File | null>(null);
const [customsBondDoc, setCustomsBondDoc] = useState<File | null>(null);
const [insuranceValidityDate, setInsuranceValidityDate] = useState('');
const [bondValidityDate, setBondValidityDate] = useState('');
// Step 8 — Agent Network and ICT Capability
const [overseasAgentAgreement, setOverseasAgentAgreement] = useState<File | null>(null);
const [localBranchInfo, setLocalBranchInfo] = useState('');
const [ictSystemDescription, setIctSystemDescription] = useState('');
const [cargoTrackingCapability, setCargoTrackingCapability] = useState('');
const [communicationSystem, setCommunicationSystem] = useState('');
const [documentManagementCapability, setDocumentManagementCapability] = useState('');
// Step 9 — Documents
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const isForeignOrJoint = investmentClassification === 'Foreign' || investmentClassification === 'Joint';
const canNext = () => {
if (active === 0) return (
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
!!applicantType && !!ownershipTypeCompany &&
!!businessAddress.trim() && !!headOfficeAddress.trim() &&
!!hasBranchOffices && (hasBranchOffices === 'No' || !!branchOfficeAddress.trim())
);
if (active === 1) return (
!!ownershipType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
!!sharePercentage && !!investmentClassification &&
(!isForeignOrJoint || !!investmentPermitNumber.trim()) &&
beneficialOwnershipDeclared
);
if (active === 2) return (
!!gmName.trim() && !!gmQualification.trim() && !!gmExperience.trim() && !!boardMembers.trim() &&
!!gmCv && !!gmEducationCert && !!gmExperienceEvidence
);
if (active === 3) return (
!!empFullName.trim() && !!empPosition.trim() && !!empQualification.trim() &&
!!empExperience.trim() && !!empEmploymentType
);
if (active === 4) return (
!!terminalLocation.trim() && !!terminalSize.trim() && !!terminalOwnership && !!terminalLeaseDoc &&
!!warehouseAvailability &&
(warehouseAvailability === 'No' || (!!warehouseSize.trim() && !!terminalSecurityInfo.trim() && !!terminalPhotos))
);
if (active === 5) return (
!!numberOfTrucks && !!truckPlateNumber.trim() && !!truckCapacity.trim() && !!truckOwnership && !!vehicleRegDoc &&
(truckOwnership !== 'Rented' || !!vehicleRentalContract) &&
!!cargoHandlingEquipment.trim() && !!equipmentOwnership &&
(equipmentOwnership !== 'Rented' || !!equipmentEvidence)
);
if (active === 6) return (
!!paidUpCapitalAmount && !!bankConfirmation && !!bankDepositEvidence
);
if (active === 7) return (
!!cargoLiabilityInsurance && !!mtoLiabilityInsurance && !!customsBondDoc &&
!!insuranceValidityDate.trim() && !!bondValidityDate.trim()
);
if (active === 8) return (
!!overseasAgentAgreement && !!localBranchInfo.trim() && !!ictSystemDescription.trim() &&
!!cargoTrackingCapability.trim() && !!communicationSystem.trim() && !!documentManagementCapability.trim()
);
if (active === 9) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]) &&
(!isForeignOrJoint || !!files['investmentPermit']);
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 submitTrigger({
url: '/logistics-licenses/mto',
method: 'POST',
body: {
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
applicantType, ownershipTypeCompany, businessAddress, headOfficeAddress,
hasBranchOffices, branchOfficeAddress,
ownershipType, shareholderName, shareholderNationality, sharePercentage,
investmentClassification, investmentPermitNumber, beneficialOwnershipDeclared,
gmName, gmQualification, gmExperience, boardMembers,
empFullName, empPosition, empQualification, empExperience, empEmploymentType,
terminalLocation, terminalSize, terminalOwnership, warehouseAvailability, warehouseSize, terminalSecurityInfo,
numberOfTrucks, truckPlateNumber, truckCapacity, truckOwnership, cargoHandlingEquipment, equipmentOwnership,
paidUpCapitalAmount,
insuranceValidityDate, bondValidityDate,
localBranchInfo, ictSystemDescription, cargoTrackingCapability, communicationSystem, documentManagementCapability,
},
}).unwrap();
notify.success('MTO License application submitted successfully!');
navigate('/mto-license');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
Back
</Button>
</Group>
<div>
<Title order={3}>MTO License Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<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>
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Your account email and phone number will be used automatically no need to re-enter them here.
</Alert>
<SectionHead title="Company / Applicant Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Company / Operator Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
<TextInput label="TIN" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
<TextInput label="Commercial Registration No." required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
<TextInput label="Business License No." required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipTypeCompany} onChange={setOwnershipTypeCompany} />
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
<TextInput label="Head Office Address" required value={headOfficeAddress} onChange={(e) => setHeadOfficeAddress(e.currentTarget.value)} />
<Select label="Has Branch Offices?" required data={['Yes', 'No']} value={hasBranchOffices} onChange={setHasBranchOffices} />
{hasBranchOffices === 'Yes' && (
<TextInput label="Branch Office Address" required value={branchOfficeAddress} onChange={(e) => setBranchOfficeAddress(e.currentTarget.value)} />
)}
</SimpleGrid>
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<SectionHead title="Ownership Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Ownership Type" required data={['Sole', 'Partnership', 'Shareholding Company']} value={ownershipType} onChange={setOwnershipType} />
<TextInput label="Shareholder Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
<TextInput label="Shareholder Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
<Select label="Investment Classification" required data={['Local', 'Foreign', 'Joint']} value={investmentClassification} onChange={setInvestmentClassification} />
{isForeignOrJoint && (
<TextInput label="Investment Permit Number" required value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
)}
</SimpleGrid>
<Checkbox
mt="sm"
label="I declare the beneficial ownership information provided above is accurate and complete."
checked={beneficialOwnershipDeclared}
onChange={(e) => setBeneficialOwnershipDeclared(e.currentTarget.checked)}
/>
</Stack>
)}
{active === 2 && (
<Stack gap="md">
<SectionHead title="General Manager" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="General Manager Name" required value={gmName} onChange={(e) => setGmName(e.currentTarget.value)} />
<TextInput label="General Manager Qualification" required value={gmQualification} onChange={(e) => setGmQualification(e.currentTarget.value)} />
<TextInput label="General Manager Experience" required value={gmExperience} onChange={(e) => setGmExperience(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Board of Directors" />
<Textarea
label="Board Member List"
description="List each board member's name and role"
required
rows={3}
value={boardMembers}
onChange={(e) => setBoardMembers(e.currentTarget.value)}
/>
<SectionHead title="Supporting Documents" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'gmCv', label: 'CV Upload', description: 'General Manager CV', required: true, icon: IconFileDescription }} file={gmCv} onFile={setGmCv} />
<DocCard slot={{ key: 'gmEducationCert', label: 'Education Certificate Upload', description: 'General Manager education certificate', required: true, icon: IconFileDescription }} file={gmEducationCert} onFile={setGmEducationCert} />
<DocCard slot={{ key: 'gmExperienceEvidence', label: 'Experience Evidence Upload', description: 'Proof of relevant work experience', required: true, icon: IconFileDescription }} file={gmExperienceEvidence} onFile={setGmExperienceEvidence} />
</SimpleGrid>
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<SectionHead title="Key Employee Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Full Name" required value={empFullName} onChange={(e) => setEmpFullName(e.currentTarget.value)} />
<TextInput label="Position" required value={empPosition} onChange={(e) => setEmpPosition(e.currentTarget.value)} />
<TextInput label="Qualification" required value={empQualification} onChange={(e) => setEmpQualification(e.currentTarget.value)} />
<TextInput label="Experience" required value={empExperience} onChange={(e) => setEmpExperience(e.currentTarget.value)} />
<Select label="Employment Type" required data={['Permanent', 'Contract', 'Part-Time']} value={empEmploymentType} onChange={setEmpEmploymentType} />
</SimpleGrid>
<SectionHead title="Employee Documents" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'empProfCert', label: 'Professional Certificate', description: 'Optional professional certification', required: false, icon: IconFileDescription }} file={empProfCert} onFile={setEmpProfCert} />
<DocCard slot={{ key: 'empContract', label: 'Employment Contract', description: 'Optional employment contract document', required: false, icon: IconFileDescription }} file={empContract} onFile={setEmpContract} />
</SimpleGrid>
</Stack>
)}
{active === 4 && (
<Stack gap="md">
<SectionHead title="Terminal Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Terminal Location" required value={terminalLocation} onChange={(e) => setTerminalLocation(e.currentTarget.value)} />
<TextInput label="Terminal Size" required value={terminalSize} onChange={(e) => setTerminalSize(e.currentTarget.value)} />
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
<DocCard slot={{ key: 'terminalLeaseDoc', label: 'Terminal Lease / Title Document', description: 'Ownership or lease document for the terminal', required: true, icon: IconBuildingWarehouse }} file={terminalLeaseDoc} onFile={setTerminalLeaseDoc} />
</SimpleGrid>
<SectionHead title="Warehouse & Security" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Warehouse Availability" required data={['Yes', 'No']} value={warehouseAvailability} onChange={setWarehouseAvailability} />
{warehouseAvailability === 'Yes' && (
<TextInput label="Warehouse Size" required value={warehouseSize} onChange={(e) => setWarehouseSize(e.currentTarget.value)} />
)}
</SimpleGrid>
{warehouseAvailability === 'Yes' && (
<>
<Textarea
label="Terminal Security / Fence Information"
required
rows={3}
value={terminalSecurityInfo}
onChange={(e) => setTerminalSecurityInfo(e.currentTarget.value)}
/>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'terminalPhotos', label: 'Terminal Photos / Inspection Evidence', description: 'Photos or evidence of terminal facility', required: true, icon: IconCamera }} file={terminalPhotos} onFile={setTerminalPhotos} />
</SimpleGrid>
</>
)}
</Stack>
)}
{active === 5 && (
<Stack gap="md">
<SectionHead title="Fleet Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<NumberInput label="Number of Trucks" required min={0} value={numberOfTrucks} onChange={setNumberOfTrucks} />
<TextInput label="Truck Plate Number" required value={truckPlateNumber} onChange={(e) => setTruckPlateNumber(e.currentTarget.value)} />
<TextInput label="Truck Capacity" required value={truckCapacity} onChange={(e) => setTruckCapacity(e.currentTarget.value)} />
<Select label="Truck Ownership Type" required data={['Owned', 'Rented']} value={truckOwnership} onChange={setTruckOwnership} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
<DocCard slot={{ key: 'vehicleRegDoc', label: 'Vehicle Registration Document', description: 'Registration document for the fleet', required: true, icon: IconTruck }} file={vehicleRegDoc} onFile={setVehicleRegDoc} />
{truckOwnership === 'Rented' && (
<DocCard slot={{ key: 'vehicleRentalContract', label: 'Vehicle Rental Contract', description: 'Rental contract for the fleet', required: true, icon: IconTruck }} file={vehicleRentalContract} onFile={setVehicleRentalContract} />
)}
</SimpleGrid>
<SectionHead title="Cargo Handling Equipment" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Cargo Handling Equipment" required value={cargoHandlingEquipment} onChange={(e) => setCargoHandlingEquipment(e.currentTarget.value)} />
<Select label="Equipment Ownership / Rental" required data={['Owned', 'Rented']} value={equipmentOwnership} onChange={setEquipmentOwnership} />
</SimpleGrid>
{equipmentOwnership === 'Rented' && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'equipmentEvidence', label: 'Equipment Ownership / Rental Evidence', description: 'Ownership or rental evidence for equipment', required: true, icon: IconTruck }} file={equipmentEvidence} onFile={setEquipmentEvidence} />
</SimpleGrid>
)}
</Stack>
)}
{active === 6 && (
<Stack gap="md">
<SectionHead title="Financial Capacity" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<NumberInput label="Paid-up Capital Amount (ETB)" required min={0} value={paidUpCapitalAmount} onChange={setPaidUpCapitalAmount} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
<DocCard slot={{ key: 'bankConfirmation', label: 'Bank Confirmation', description: 'Bank letter confirming capital', required: true, icon: IconFileDescription }} file={bankConfirmation} onFile={setBankConfirmation} />
<DocCard slot={{ key: 'bankDepositEvidence', label: 'Bank Deposit Evidence', description: 'Evidence of the capital deposit', required: true, icon: IconFileDescription }} file={bankDepositEvidence} onFile={setBankDepositEvidence} />
<DocCard slot={{ key: 'auditedFinancialStatement', label: 'Audited Financial Statement', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={auditedFinancialStatement} onFile={setAuditedFinancialStatement} />
<DocCard slot={{ key: 'assetValuationEvidence', label: 'Asset Valuation Evidence', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={assetValuationEvidence} onFile={setAssetValuationEvidence} />
</SimpleGrid>
</Stack>
)}
{active === 7 && (
<Stack gap="md">
<SectionHead title="Insurance, Bond & Liability" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'cargoLiabilityInsurance', label: 'Cargo Liability Insurance', description: 'Insurance covering cargo liability', required: true, icon: IconShieldCheck }} file={cargoLiabilityInsurance} onFile={setCargoLiabilityInsurance} />
<DocCard slot={{ key: 'mtoLiabilityInsurance', label: 'Multimodal Transport Liability Insurance', description: 'Insurance covering multimodal transport liability', required: true, icon: IconShieldCheck }} file={mtoLiabilityInsurance} onFile={setMtoLiabilityInsurance} />
<DocCard slot={{ key: 'customsBondDoc', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck }} file={customsBondDoc} onFile={setCustomsBondDoc} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
<AmharicDatePicker label="Insurance Validity Date" required value={insuranceValidityDate} onChange={setInsuranceValidityDate} />
<AmharicDatePicker label="Bond Validity Date" required value={bondValidityDate} onChange={setBondValidityDate} />
</SimpleGrid>
</Stack>
)}
{active === 8 && (
<Stack gap="md">
<SectionHead title="Agent Network" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocCard slot={{ key: 'overseasAgentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription }} file={overseasAgentAgreement} onFile={setOverseasAgentAgreement} />
<TextInput label="Local Branch Office Information" required value={localBranchInfo} onChange={(e) => setLocalBranchInfo(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="ICT Capability" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Cargo Tracking Capability" required value={cargoTrackingCapability} onChange={(e) => setCargoTrackingCapability(e.currentTarget.value)} />
<TextInput label="Communication System" required value={communicationSystem} onChange={(e) => setCommunicationSystem(e.currentTarget.value)} />
<TextInput label="Document Management Capability" required value={documentManagementCapability} onChange={(e) => setDocumentManagementCapability(e.currentTarget.value)} />
</SimpleGrid>
<Textarea
label="ICT System Description"
required
rows={3}
value={ictSystemDescription}
onChange={(e) => setIctSystemDescription(e.currentTarget.value)}
/>
</Stack>
)}
{active === 9 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{active === 10 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. Your application requires a physical inspection of your terminal, warehouse, trucks, office, and equipment.
If approved, you will be asked to pay 1000 ETB before certificate issuance.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company / Applicant Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Company Name" value={companyName} />
<ReviewRow label="Trade Name" value={tradeName} />
<ReviewRow label="TIN" value={tinNumber} />
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
<ReviewRow label="Business License No." value={businessLicenseNumber} />
<ReviewRow label="Applicant Type" value={applicantType ?? ''} />
<ReviewRow label="Ownership Type" value={ownershipTypeCompany ?? ''} />
<ReviewRow label="Business Address" value={businessAddress} />
<ReviewRow label="Head Office Address" value={headOfficeAddress} />
{hasBranchOffices === 'Yes' && <ReviewRow label="Branch Office Address" value={branchOfficeAddress} />}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Ownership Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Ownership Type" value={ownershipType ?? ''} />
<ReviewRow label="Shareholder Name" value={shareholderName} />
<ReviewRow label="Shareholder Nationality" value={shareholderNationality} />
<ReviewRow label="Share Percentage" value={`${sharePercentage || ''}%`} />
<ReviewRow label="Investment Classification" value={investmentClassification ?? ''} />
{isForeignOrJoint && <ReviewRow label="Investment Permit Number" value={investmentPermitNumber} />}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Management, Employees & Terminal</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="General Manager" value={gmName} />
<ReviewRow label="Employee" value={empFullName} />
<ReviewRow label="Terminal Location" value={terminalLocation} />
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
<ReviewRow label="Warehouse Availability" value={warehouseAvailability ?? ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Fleet & Financial Capacity</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Number of Trucks" value={`${numberOfTrucks || ''}`} />
<ReviewRow label="Truck Ownership" value={truckOwnership ?? ''} />
<ReviewRow label="Paid-up Capital" value={`${Number(paidUpCapitalAmount || 0).toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/mto-license') : prev}>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
) : (
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
Submit Application
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,230 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconCertificate,
IconCheck,
IconCircleCheck,
IconClockHour4,
IconDownload,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Inspection Pending' | 'Inspection Completed' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
interface MtoApplication {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
'Inspection Pending': 'grape', 'Inspection Completed': 'indigo',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
};
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
export function MtoLicensePage() {
const navigate = useNavigate();
const [application, setApplication] = useState<MtoApplication | null>(null);
const [fetchTrigger] = useApiMutation<MtoApplication>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/mto/my', method: 'GET' })
.unwrap()
.then((data) => setApplication(data))
.catch(() => {/* no application yet */});
}, [fetchTrigger]);
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Multimodal Transport Operator License</Title>
<Text fz="sm" c="dimmed">Apply for and manage your Multimodal Transport Operator (MTO) License</Text>
</div>
</Group>
{!application && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Apply for an MTO License</Text>
<Text fz="sm" c="dimmed">Provide company, ownership, management, terminal, fleet, financial, and insurance information</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Company registration, TIN, and business license documents" />
<RequirementItem label="Ownership and beneficial ownership declaration" />
<RequirementItem label="Management and board information with CVs" />
<RequirementItem label="Terminal / warehouse lease or title documents" />
<RequirementItem label="Fleet and cargo handling equipment documentation" />
<RequirementItem label="Proof of paid-up capital and bank confirmation" />
<RequirementItem label="Cargo and multimodal transport liability insurance, customs bond" />
<RequirementItem label="Passport-size photo for certificate printing" />
</Stack>
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/mto-license/apply')}>
Start Application
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About MTO Licensing</Text>
</Group>
<Text fz="sm" c="dimmed">
Applications require a physical inspection of your terminal, warehouse, trucks, office, and equipment.
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
The certificate is valid for <strong>one year</strong> and must be renewed annually.
</Text>
</Paper>
</>
)}
{application && (
<>
{application.status === 'Resubmit Required' && (
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
{application.remarks || 'Please correct the requested information and resubmit.'}
</Alert>
)}
{application.status === 'Rejected' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
{application.remarks || 'Your application was rejected.'}
</Alert>
)}
{application.status === 'Inspection Pending' && (
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Inspection Pending">
A physical inspection of your terminal, warehouse, trucks, office, and equipment is required. An Inspector will contact you to schedule the visit.
</Alert>
)}
{application.status === 'Inspection Completed' && (
<Alert icon={<IconCircleCheck size={17} />} color="indigo" title="Inspection Completed">
Your physical inspection has been completed by an Inspector. Your application is proceeding to the next stage.
</Alert>
)}
{application.status === 'Payment Pending' && (
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
Your application has been approved. Please pay 1000 ETB to receive your certificate.
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
</Alert>
)}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{application.companyName}</Text>
<Text fz="xs" c="dimmed">{application.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
</Group>
{application.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{application.remarks}</Text>
</>
)}
</Paper>
{application.status !== 'Certificate Issued' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Evaluation', done: application.status !== 'Submitted' && application.status !== 'Under Review' },
{ label: 'Inspection Pending', done: ['Inspection Pending', 'Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Inspection Completed', done: ['Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{application.status === 'Certificate Issued' && (
<div>
<Group gap="xs" mb="sm">
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">Issued Certificate</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your Multimodal Transport Operator License certificate is ready. Valid until {application.expiryDate}.
</Alert>
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
<div>
<Text fw={600} fz="sm">Multimodal Transport Operator License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,151 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { FileButton } from '@mantine/core';
import { notify } from '@ema-platform/ui';
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
}
const RENEWAL_DOCS: DocSlot[] = [
{ key: 'prevCertificate', label: 'Previous MTO License Certificate', description: 'Your current/expiring certificate', required: true },
{ key: 'taxPaper', label: 'Tax Paper', description: 'Current tax clearance document', required: true },
{ key: 'payroll', label: 'Employee Payroll', description: 'Payroll evidence for employees', required: true },
{ key: 'updatedEmployeeList', label: 'Updated Employee List', description: 'Current list of employees', required: true },
{ key: 'updatedInsurance', label: 'Updated Insurance Certificate', description: 'Current insurance certificate', required: true },
{ key: 'customsBondRenewal', label: 'Customs Bond Renewal', description: 'Required only if the customs bond has been renewed', required: false },
{ key: 'terminalLeaseRenewal', label: 'Terminal Lease / Rent Contract', description: 'Required only if the previous contract has expired', required: false },
{ key: 'truckRentalRenewal', label: 'Truck / Car Rental Contract', description: 'Required only if the previous contract has expired', required: false },
{ key: 'equipmentRentalRenewal', label: 'Equipment Rental Contract', description: 'Required only if the previous contract has expired', required: false },
{ key: 'branchOfficeContract', label: 'Branch Office Contract', description: 'Required only if the previous contract has expired', required: false },
{ key: 'complianceDeclaration', label: 'Compliance Declaration', description: 'Signed compliance declaration', required: true },
{ key: 'paymentReceipt', label: 'Payment Receipt', description: 'Receipt of renewal service payment, if already paid', required: false },
];
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
export function MtoLicenseRenewalPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [submitting, setSubmitting] = useState(false);
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({ url: '/logistics-licenses/mto/renew', method: 'POST', body: {} }).unwrap();
notify.success('Renewal request submitted successfully!');
navigate('/mto-license');
} catch {
notify.error('Renewal submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
Back
</Button>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Renew MTO License</Title>
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
</div>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
If your terminal, truck, equipment, or branch office contract has expired since your last submission, an updated contract is required.
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
<Stack gap="md">
{RENEWAL_DOCS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</Stack>
<Group justify="flex-end" mt="xl">
<Button
color="teal"
leftSection={<IconCheck size={16} />}
loading={submitting}
disabled={!canSubmit}
onClick={handleSubmit}
>
Submit Renewal Request
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,331 +1,123 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Center,
Container,
Group,
Paper,
Select,
SimpleGrid,
Loader,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import { IconBellOff, IconCheck } from '@tabler/icons-react';
import {
IconAlertCircle,
IconAlertTriangle,
IconBell,
IconBellOff,
IconBook2,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconHeart,
IconInfoCircle,
IconShield,
IconTrash,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
localized,
useGetNotificationsQuery,
useMarkNotificationReadMutation,
} from '@ema-platform/api';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* The applicant's notification inbox.
*
* Backed by the real notification records the licensing workflow writes at
* every transition — this previously listed a fixed array of invented alerts.
*/
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
const [filter, setFilter] = useState<string>('All');
const [readFilter, setReadFilter] = useState<string | null>(null);
const navigate = useNavigate();
const { data, isLoading } = useGetNotificationsQuery();
const [markRead] = useMarkNotificationReadMutation();
const unreadCount = notifications.filter((n) => !n.read).length;
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
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 items = data?.items ?? [];
const unread = items.filter((n) => !n.isSeen).length;
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.');
};
async function open(id: string, seen: boolean, link?: string) {
if (!seen) await markRead(id);
if (link) navigate(link);
}
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>
<Container size="md" py="md">
<Title order={3}>Notifications</Title>
<Text size="sm" c="dimmed" mb="md">
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
</Text>
{/* 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>
{items.length === 0 ? (
<Card withBorder padding="xl">
<Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">No notifications yet.</Text>
<Text size="sm" c="dimmed">
You will be notified as your applications progress.
</Text>
</Stack>
</Card>
) : (
<Stack gap="xs">
{filtered.map((n) => {
const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
const CatIcon = CATEGORY_ICON[n.category];
{items.map((n) => {
const link = (n.metadata?.['callbackUrl'] as string) ?? undefined;
return (
<Card
key={n.id}
withBorder
padding="md"
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,
cursor: link ? 'pointer' : 'default',
borderLeft: n.isSeen ? undefined : '3px solid var(--mantine-color-blue-5)',
}}
onClick={() => markRead(n.id)}
onClick={() => open(n.id, n.isSeen, link)}
>
<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}
<div style={{ minWidth: 0 }}>
<Group gap="xs" mb={2}>
<Text fw={600} size="sm">
{localized(n.subject)}
</Text>
{!n.isSeen && (
<Badge size="xs" variant="light">
new
</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
)}
</Group>
<Text size="sm" c="dimmed">
{localized(n.content)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{new Date(n.createdAt).toLocaleString()}
</Text>
</div>
{!n.isSeen && (
<Button
size="compact-xs"
variant="subtle"
color="red"
size="sm"
title="Delete"
onClick={(e) => { e.stopPropagation(); deleteNotif(n.id); }}
leftSection={<IconCheck size={12} />}
onClick={(e) => {
e.stopPropagation();
markRead(n.id);
}}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
Mark read
</Button>
)}
</Group>
</Card>
);
})}
</Stack>
)}
</Stack>
</Container>
);
}
export default NotificationsPage;

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import { notifications } from '@mantine/notifications';
import {
extractErrorMessage,
useInitiatePaymentMutation,
} from '@ema-platform/api';
/**
* Starts a payment and hands the browser over to the provider.
*
* Telebirr on web answers with a `REDIRECT` action carrying a signed checkout
* URL; on mobile it answers with `LAUNCH_APP` so the Telebirr app can be opened
* directly. Anything else means the provider gave us nothing to act on, which
* is reported rather than silently doing nothing.
*/
export function useApplicationPayment() {
const [initiate, { isLoading }] = useInitiatePaymentMutation();
const [redirecting, setRedirecting] = useState(false);
async function pay(
applicationId: string,
provider = 'TELEBIRR',
): Promise<void> {
try {
const result = await initiate({
id: applicationId,
provider,
// Deep links only work inside a mobile browser; assume web otherwise.
platform: /Android|iPhone|iPad/i.test(navigator.userAgent)
? 'mobile'
: 'web',
}).unwrap();
const action = result.clientAction;
if (action?.type === 'REDIRECT' && action.url) {
setRedirecting(true);
// Full navigation, not a router push: the destination is Telebirr.
window.location.href = action.url;
return;
}
if (action?.type === 'LAUNCH_APP') {
notifications.show({
color: 'blue',
title: 'Open Telebirr',
message: `Approve the payment in your Telebirr app${
action.receiveCode ? ` using code ${action.receiveCode}` : ''
}.`,
autoClose: false,
});
return;
}
notifications.show({
color: 'orange',
title: 'Payment could not be started',
message: 'The payment provider did not return a checkout link.',
});
} catch (err) {
notifications.show({
color: 'red',
title: 'Payment could not be started',
message: extractErrorMessage(err),
});
}
}
return { pay, isPaying: isLoading || redirecting };
}

View File

@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Center,
Container,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertTriangle,
IconCircleCheck,
IconClockHour4,
} from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
const POLL_INTERVAL_MS = 3000;
const MAX_ATTEMPTS = 10;
/**
* Where Telebirr returns the applicant after paying.
*
* The redirect usually beats the webhook, so the payment is polled for a
* short while. Running out of attempts means "not confirmed yet" — never
* "failed": telling someone their payment failed when the money has left
* their account is the worst possible outcome here.
*/
export function PaymentCheckPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? '';
const [attempts, setAttempts] = useState(0);
const { data, refetch, isLoading } = useGetApplicationPaymentQuery(
applicationId,
{ skip: !applicationId },
);
const status = data?.status ?? null;
const settled = status === 'PAID' || status === 'FAILED' || status === 'CANCELLED';
useEffect(() => {
if (!applicationId || settled || attempts >= MAX_ATTEMPTS) return;
const timer = setTimeout(() => {
refetch();
setAttempts((n) => n + 1);
}, POLL_INTERVAL_MS);
return () => clearTimeout(timer);
}, [applicationId, settled, attempts, refetch]);
useEffect(() => {
if (status === 'PAID') navigate(`/payments/success?applicationId=${applicationId}`);
if (status === 'FAILED' || status === 'CANCELLED') {
navigate(`/payments/failure?applicationId=${applicationId}`);
}
}, [status, applicationId, navigate]);
if (!applicationId) {
return (
<Container size="sm" py="xl">
<Card withBorder padding="xl">
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" color="orange" variant="light">
<IconAlertTriangle size={24} />
</ThemeIcon>
<Title order={4}>We could not identify this payment</Title>
<Text size="sm" c="dimmed" ta="center">
Open the application from your list to check its payment status.
</Text>
<Button onClick={() => navigate('/licensing/applications')}>
My applications
</Button>
</Stack>
</Card>
</Container>
);
}
const exhausted = attempts >= MAX_ATTEMPTS && !settled;
return (
<Container size="sm" py="xl">
<Card withBorder padding="xl">
<Stack align="center" gap="sm">
{exhausted ? (
<>
<ThemeIcon size={48} radius="xl" color="yellow" variant="light">
<IconClockHour4 size={24} />
</ThemeIcon>
<Title order={4}>Still confirming your payment</Title>
<Text size="sm" c="dimmed" ta="center">
Telebirr has not confirmed this payment yet. If the money has
left your account it will be applied automatically there is no
need to pay again.
</Text>
<Group>
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
Check again
</Button>
<Button onClick={() => navigate('/licensing/applications')}>
My applications
</Button>
</Group>
</>
) : (
<>
{isLoading || !settled ? <Loader /> : (
<ThemeIcon size={48} radius="xl" color="teal" variant="light">
<IconCircleCheck size={24} />
</ThemeIcon>
)}
<Title order={4}>Confirming your payment</Title>
<Text size="sm" c="dimmed" ta="center">
This usually takes a few seconds. Please do not close this page.
</Text>
</>
)}
</Stack>
</Card>
</Container>
);
}
export default PaymentCheckPage;

View File

@@ -0,0 +1,51 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Container,
Group,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
/** Shown when Telebirr reported the payment as failed or cancelled. */
export function PaymentFailurePage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? '';
const { data } = useGetApplicationPaymentQuery(applicationId, {
skip: !applicationId,
});
return (
<Container size="sm" py="xl">
<Card withBorder padding="xl" radius="md">
<Stack align="center" gap="sm">
<ThemeIcon size={56} radius="xl" color="red" variant="light">
<IconAlertTriangle size={30} />
</ThemeIcon>
<Title order={3}>Payment not completed</Title>
<Text size="sm" c="dimmed" ta="center">
{data?.failureReason
? data.failureReason
: 'The payment was not completed. Nothing has been charged.'}
</Text>
<Text size="xs" c="dimmed" ta="center">
Your application is unchanged and you can try again at any time.
</Text>
<Group mt="md">
<Button variant="default" onClick={() => navigate('/licensing/applications')}>
My applications
</Button>
</Group>
</Stack>
</Card>
</Container>
);
}
export default PaymentFailurePage;

View File

@@ -0,0 +1,77 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Button,
Card,
Container,
Divider,
Group,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react';
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
/** Confirmation that the licence fee has been received. */
export function PaymentSuccessPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? '';
const { data } = useGetApplicationPaymentQuery(applicationId, {
skip: !applicationId,
});
return (
<Container size="sm" py="xl">
<Card withBorder padding="xl" radius="md">
<Stack align="center" gap="sm">
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCircleCheck size={30} />
</ThemeIcon>
<Title order={3}>Payment received</Title>
<Text size="sm" c="dimmed" ta="center">
Thank you. Your licence fee has been paid and your application is
being finalised. You will be notified when your certificate is ready.
</Text>
{data && (
<>
<Divider my="xs" w="100%" />
<Stack gap={4} w="100%">
<Group justify="space-between">
<Text size="sm" c="dimmed">Amount</Text>
<Text size="sm" fw={600}>
{Number(data.amount).toLocaleString()} {data.currency}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Method</Text>
<Text size="sm">{data.provider}</Text>
</Group>
{data.providerRef && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Reference</Text>
<Text size="sm" ff="monospace">{data.providerRef}</Text>
</Group>
)}
{data.paidAt && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Paid</Text>
<Text size="sm">{new Date(data.paidAt).toLocaleString()}</Text>
</Group>
)}
</Stack>
</>
)}
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
Back to my applications
</Button>
</Stack>
</Card>
</Container>
);
}
export default PaymentSuccessPage;

View File

@@ -1,431 +0,0 @@
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, useErrorHandler } from "@ema-platform/ui";
import {
authStorage,
setUser,
setCurrentProfile,
logout,
type AuthUser,
type CurrentProfile,
} 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<{ id: string }>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{
total: number;
items: CurrentProfile[];
}>();
const [fetchProfessions] = useApiMutation<{
count: number;
items: Array<{ id: string; name: { en: string } }>;
}>();
const { handleError } = useErrorHandler();
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 addressData = await addressTrigger({
url: `/addresss`,
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 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,
addressId: addressData.id,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
const me = await meTrigger({ url: "/auth/me", method: "GET" }).unwrap();
dispatch(setUser(me));
// POST /profiles doesn't return the profession relation the portal's nav/route
// guard reads (PortalLayout.tsx) — re-fetch with it, same as LoginPage does on login.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const profileCheck = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`,
method: "GET",
}).unwrap();
if (profileCheck.total > 0 && profileCheck.items.length > 0) {
dispatch(setCurrentProfile(profileCheck.items[0]));
}
} catch {
// non-fatal — sidebar/route guard falls back to FALLBACK_ACCESS
}
notify.success("Profile setup complete!");
navigate("/dashboard");
} catch (e) {
handleError(e);
} finally {
setSubmitting(false);
}
};
if (!user) {
return (
<Center mih="100vh">
<Text c="dimmed">Please log in first.</Text>
</Center>
);
}
return (
<Center mih="100vh" bg="gray.0">
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
<Stack gap="md">
<div>
<Title order={3}>Complete Your Profile</Title>
<Text fz="sm" c="dimmed">
Set up your profile and address to get started
</Text>
</div>
<StepIndicator active={active} completed={completed} />
{active === 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<ProfileFormContent
register={profileRegister}
errors={profileErrors}
setValue={profileSetValue}
watch={profileWatch}
trigger={profileTriggerValidation}
professionsLoading={professionsLoading}
professionOptions={professionOptions}
/>
</>
)}
{active === 1 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<AddressFormContent
register={addressRegister}
errors={addressErrors}
setValue={addressSetValue}
watch={addressWatch}
trigger={addressTriggerValidation}
/>
</>
)}
<Group justify="space-between" mt="xl">
<Button
variant="default"
color="gray"
leftSection={<IconLogout2 size={16} />}
onClick={() => {
dispatch(logout());
navigate("/login");
}}
>
Sign out
</Button>
<Group gap="sm">
{active > 0 && (
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActive((c) => c - 1)}
>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button
rightSection={<IconArrowRight size={16} />}
onClick={onNext}
>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={onSubmitAddress}
loading={submitting}
>
Complete Setup
</Button>
)}
</Group>
</Group>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -20,6 +20,8 @@ export const addressSchema = z.object({
kebeleId: z.string().optional(),
streetAddress: z.string().optional(),
postalAddress: z.string().optional(),
// Emergency contact is collected but never required — leaving it blank must
// not stop an applicant moving on.
emergencyContactName: z.string().optional(),
emergencyContactPhone: z.string().optional(),
emergencyContactRelation: z.string().optional(),
@@ -150,7 +152,7 @@ export function AddressFormContent({
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact
Emergency Contact <Text span c="dimmed" fz="xs" tt="none" fw={400}>(optional)</Text>
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput

View File

@@ -0,0 +1,119 @@
import { useCallback, useState } from 'react';
import {
ActionIcon,
Button,
Group,
Paper,
RingProgress,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PROFILE_FIELD_SECTION, useCurrentProfile } from '@ema-platform/auth';
const DISMISS_KEY = 'ema-portal-profile-nudge-dismissed';
/** Nothing below this is worth interrupting anyone about. */
const NUDGE_THRESHOLD = 100;
/** How many gaps to name before falling back to a count. */
const MAX_LISTED_GAPS = 3;
function readDismissed(): boolean {
try {
return localStorage.getItem(DISMISS_KEY) === 'true';
} catch {
// Private mode — treat as not dismissed rather than hiding the nudge.
return false;
}
}
/**
* A prompt to finish the profile. Explicitly not a gate.
*
* The applicant can dismiss it, and dismissing it persists. It never prevents
* navigation and never appears on top of anything — replacing the wizard with
* a modal would just be the same wall in a smaller box.
*/
export function ProfileCompletionNudge() {
const { t } = useTranslation();
const { completeness, missing, isLoading } = useCurrentProfile();
const [dismissed, setDismissed] = useState(readDismissed);
const dismiss = useCallback(() => {
setDismissed(true);
try {
localStorage.setItem(DISMISS_KEY, 'true');
} catch {
// Not persisting a dismissal is a smaller problem than crashing here.
}
}, []);
if (isLoading || dismissed || completeness >= NUDGE_THRESHOLD) return null;
const topGaps = missing.slice(0, MAX_LISTED_GAPS);
const remaining = missing.length - topGaps.length;
const firstSection = topGaps.length ? PROFILE_FIELD_SECTION[topGaps[0]] : 'personal';
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group wrap="nowrap" gap="md" align="center">
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: completeness, color: 'emaPrimary' }]}
label={
<Text ta="center" fw={700} size="xs">
{completeness}%
</Text>
}
/>
<Stack gap={2}>
<Text fw={600} size="sm">
{t('profileNudge.title', 'Finish setting up your profile')}
</Text>
<Text size="xs" c="dimmed">
{t('profileNudge.body', {
fields: topGaps
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
.join(', '),
defaultValue: 'Still needed: {{fields}}',
})}
{remaining > 0 &&
` ${t('profileNudge.andMore', {
count: remaining,
defaultValue: 'and {{count}} more',
})}`}
</Text>
</Stack>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
component={Link}
to={`/profile#${firstSection}`}
size="xs"
variant="light"
>
{t('profileNudge.action', 'Complete profile')}
</Button>
<Tooltip label={t('profileNudge.dismiss', 'Dismiss')}>
<ActionIcon
variant="subtle"
color="gray"
onClick={dismiss}
aria-label={t('profileNudge.dismiss', 'Dismiss')}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,91 @@
import { Alert, Anchor, Button, Group, List, Stack, Text } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
PROFILE_FIELD_SECTION,
useCurrentProfile,
type ProfileRequirement,
} from '@ema-platform/auth';
import type { ReactNode } from 'react';
interface ProfileRequirementGateProps {
requirement: ProfileRequirement;
/** Rendered once the requirement is satisfied. */
children: ReactNode;
/**
* When true the children still render alongside the notice. Use for flows
* the applicant can keep working through while a detail is outstanding.
*/
advisory?: boolean;
}
/**
* Asks for missing profile details in place.
*
* Deliberately not a redirect. Sending someone to /profile mid-application
* loses their work and their place, which is what the old setup wizard did at
* a larger scale. This renders an inline card naming exactly which fields are
* outstanding and links to the tab that collects them, so the applicant can
* fill them in a second tab and come back.
*/
export function ProfileRequirementGate({
requirement,
children,
advisory = false,
}: ProfileRequirementGateProps) {
const { t } = useTranslation();
const { gapsFor, isLoading } = useCurrentProfile();
// Never block on the resolver: showing the flow and letting submission fail
// is better than a spinner over content that is probably fine.
if (isLoading) return <>{children}</>;
const gaps = gapsFor(requirement);
if (gaps.length === 0) return <>{children}</>;
const sections = [...new Set(gaps.map((field) => PROFILE_FIELD_SECTION[field]))];
return (
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={18} />}
title={t('profileGate.title', {
count: gaps.length,
defaultValue: 'We need {{count}} more detail before you continue',
defaultValue_other: 'We need {{count}} more details before you continue',
})}
>
<Stack gap="xs">
<Text size="sm">{requirement.reason}</Text>
<List size="sm" spacing={2}>
{gaps.map((field) => (
<List.Item key={field}>
{t(`profileFields.${field}`, { defaultValue: field })}
</List.Item>
))}
</List>
<Group gap="xs">
{sections.map((section) => (
<Button
key={section}
component={Link}
to={`/profile#${section}`}
size="xs"
variant="light"
>
{t('profileGate.addDetails', 'Add these details')}
</Button>
))}
<Anchor component={Link} to="/profile" size="xs" c="dimmed">
{t('profileGate.viewProfile', 'View full profile')}
</Anchor>
</Group>
</Stack>
</Alert>
{advisory && children}
</Stack>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -9,6 +9,7 @@ import {
Loader,
Paper,
PasswordInput,
RingProgress,
SimpleGrid,
Stack,
Switch,
@@ -16,6 +17,7 @@ import {
Text,
TextInput,
Title,
Tooltip,
UnstyledButton,
useMantineColorScheme,
type MantineColorScheme,
@@ -39,13 +41,14 @@ import {
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import { useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
import { setUser, useCurrentProfile } from '@ema-platform/auth';
import type { CurrentProfile } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -62,6 +65,9 @@ import {
} from '../components/AddressFormContent';
import classes from './ProfilePage.module.css';
/** Tab keys addressable via the URL hash. */
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
function getInitials(name: string, fallback: string) {
const source = name?.trim() || fallback?.trim() || '';
if (!source) return '?';
@@ -84,7 +90,7 @@ export function ProfilePage() {
const { t, i18n } = useTranslation();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const { handleError } = useErrorHandler();
@@ -126,8 +132,18 @@ export function ProfilePage() {
professions.forEach((p) => { map[p.id] = p.name.en; });
return map;
}, [professions]);
// ---- Profile data (from stored currentProfile) ----
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
// ---- Profile data ----
// Resolved through `useCurrentProfile`, which provisions a profile if the
// user has none. The page used to read an id out of local storage that only
// the deleted setup wizard ever wrote, so it rendered an empty form forever
// for anyone who signed up after the wizard was removed.
const {
profile: resolvedProfile,
isLoading: profileResolving,
completeness,
missing,
} = useCurrentProfile();
const [updateProfile] = useApiMutation<unknown>();
const [updateAddress] = useApiMutation<unknown>();
@@ -136,9 +152,28 @@ export function ProfilePage() {
const [profileId, setProfileId] = useState<string | null>(null);
const [addressId, setAddressId] = useState<string | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const profileFetched = useRef(false);
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
// requirement gates link straight at them (/profile#address), so the hash
// has to select a tab rather than being ignored. Emergency-contact fields
// live inside the address form, so both anchors land on that tab.
const tabFromHash = useCallback((hash: string) => {
const key = hash.replace('#', '');
if (key === 'emergency') return 'address';
return VALID_TABS.includes(key) ? key : 'personal';
}, []);
const [activeTab, setActiveTab] = useState(() =>
tabFromHash(typeof window === 'undefined' ? '' : window.location.hash),
);
const { hash } = useLocation();
useEffect(() => {
setActiveTab(tabFromHash(hash));
}, [hash, tabFromHash]);
useEffect(() => {
// Prefer the freshly resolved profile; fall back to whatever the store
// already holds so the form does not flash empty on a refetch.
const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) {
setProfileId(currentProfile.id);
setLoadedProfile({
@@ -170,33 +205,19 @@ export function ProfilePage() {
postalAddress: currentProfile.address.postalAddress || '',
emergencyContactName: currentProfile.address.emergencyContactName || '',
emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
emergencyContactRelation: currentProfile.address.emergencycontactRelation || '',
// Previously read `emergencycontactRelation` (lower-case c), so the
// saved relationship never appeared when reopening the profile.
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 {
} else if (!profileResolving) {
// Resolver finished and there is still nothing — render the empty form
// rather than an indefinite spinner.
setDataLoading(false);
}
}, [currentProfile, user, fetchProfile, dispatch]);
}, [resolvedProfile, storedProfile, profileResolving]);
// Load the latest user from the server on mount
useEffect(() => {
@@ -206,7 +227,9 @@ export function ProfilePage() {
.then((me) => {
if (active) dispatch(setUser(me));
})
.catch(() => {});
.catch(() => {
// Best-effort refresh; the store already holds the user from sign-in.
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -446,12 +469,50 @@ export function ProfilePage() {
{user.username}
</Badge>
)}
{/* Completeness. Informational only — nothing here blocks the user,
it just makes visible what the nudge and the in-flow gates are
reacting to. Computed by the API so all three agree. */}
<Tooltip
label={
missing.length
? missing
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
.join(', ')
: t('profileSections.sectionSaved', 'Saved')
}
multiline
w={260}
withArrow
>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: completeness, color: 'emaPrimary' }]}
aria-label={t('profileSections.completeness', {
value: completeness,
defaultValue: '{{value}}% complete',
})}
label={
<Text ta="center" fw={700} size="xs">
{completeness}%
</Text>
}
/>
</Tooltip>
</Group>
</Paper>
{/* Tabs */}
<Tabs
defaultValue="personal"
value={activeTab}
onChange={(value) => {
const next = value ?? 'personal';
setActiveTab(next);
// Keep the URL shareable without pushing a history entry per tab.
window.history.replaceState(null, '', `#${next}`);
}}
variant="pills"
classNames={{ list: classes.list, tab: classes.tab }}
>

View File

@@ -1,446 +1,21 @@
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";
import { Container } from '@mantine/core';
import { FeatureUnavailable } 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;
const MOCK_APPLICATION: SeamanBookApp = {
id: "SB-2026-001",
submittedAt: "2026-07-02T09:15:00Z",
status: "Approved",
remarks: "Seaman Book issued successfully.",
timeline: [
{
date: "2026-07-02T09:15:00Z",
event: "Application Submitted",
done: true,
},
{
date: "2026-07-03T11:30:00Z",
event: "Document Verification",
done: true,
},
{
date: "2026-07-04T14:10:00Z",
event: "Application Reviewed",
done: true,
},
{
date: "2026-07-05T10:00:00Z",
event: "Seaman Book Issued",
done: true,
},
],
};
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
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function SeamanBookPage() {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length;
const isEligible =
ELIGIBILITY.hasProfile &&
ELIGIBILITY.hasNationalId &&
ELIGIBILITY.hasMedicalCert &&
ELIGIBILITY.bstComplete;
const handleApply = async () => {
setSubmitting(true);
await new Promise((r) => setTimeout(r, 1400));
setSubmitting(false);
setSubmitted(true);
notify.success(
"Seaman Book application submitted successfully! Reference: SB-APP-2024-002",
);
};
const activeStep = MOCK_APPLICATION
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1
: -1;
return (
<Stack gap="md">
{/* Header */}
<div>
<Title order={3}>My Application Seaman Book & BTC</Title>
<Text fz="sm" c="dimmed">
A Seaman Book is your official maritime identity document. It records
your sea service and must be held before joining any vessel.
</Text>
</div>
{/* Active application status */}
{MOCK_APPLICATION && (
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconBook2 size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text>
<Text fz="xs" c="dimmed">
Submitted {MOCK_APPLICATION.submittedAt}
</Text>
</div>
</Group>
<Badge
color={STATUS_COLOR[MOCK_APPLICATION.status] ?? "gray"}
variant="light"
size="lg"
>
{MOCK_APPLICATION.status}
</Badge>
</Group>
{MOCK_APPLICATION.remarks && (
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={15} />}
mb="md"
p="sm"
>
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
</Alert>
)}
{/* Progress stepper */}
<Stepper active={activeStep} size="sm" color="teal">
{MOCK_APPLICATION.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? "Pending"}
icon={
step.done ? (
<IconCircleCheck size={16} />
) : (
<IconClock size={16} />
)
}
/>
))}
</Stepper>
{MOCK_APPLICATION.status === "Ready for Collection" && (
<Alert
variant="light"
color="teal"
icon={<IconPrinter size={17} />}
mt="md"
>
Your Seaman Book is ready. Please visit the EMA office to collect
it. Bring your National ID.
</Alert>
)}
</Paper>
)}
{/* No active application — eligibility + apply */}
{!submitted && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* Eligibility checklist */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon
variant="light"
color={isEligible ? "teal" : "orange"}
size={36}
radius="md"
>
<IconShield size={18} />
</ThemeIcon>
<Text fw={700}>Eligibility Requirements</Text>
</Group>
<Stack gap="sm">
<EligibilityItem
label="Profile completed (name, DOB, nationality)"
ok={ELIGIBILITY.hasProfile}
/>
<EligibilityItem
label="National ID / Fayda uploaded"
ok={ELIGIBILITY.hasNationalId}
/>
<EligibilityItem
label="Valid medical certificate uploaded"
ok={ELIGIBILITY.hasMedicalCert}
/>
<Divider
label="Basic Safety Training (all 5 required)"
labelPosition="left"
my={4}
/>
{ELIGIBILITY.bstItems.map((item) => (
<EligibilityItem
key={item.label}
label={item.label}
ok={item.done}
/>
))}
{!isEligible && (
<Alert
variant="light"
color="orange"
icon={<IconAlertCircle size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
Complete all requirements above before applying. Missing
BST: {5 - bstDone} certificate(s).
</Text>
</Alert>
)}
{isEligible && (
<Alert
variant="light"
color="teal"
icon={<IconCircleCheck size={15} />}
mt="xs"
p="sm"
>
<Text fz="xs">
You meet all requirements. You may proceed with your
application.
</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Application form */}
<Paper withBorder radius="lg" p="lg">
<Group mb="md" gap="xs">
<ThemeIcon variant="light" color="blue" size={36} radius="md">
<IconFileDescription size={18} />
</ThemeIcon>
<Text fw={700}>New Application</Text>
</Group>
<Stack gap="sm">
<Text fz="sm" c="dimmed" lh={1.6}>
Upon submitting your application, EMA Registration Officers will
verify your profile, documents, medical certificate, and Basic
Safety Training certificates. You will be notified at each stage
by email and SMS.
</Text>
<Divider />
<Text fw={600} fz="sm">
What will be verified:
</Text>
<Stack gap={6}>
{[
"Full seafarer profile",
"National ID / Fayda authenticity",
"Medical certificate validity",
"All 5 Basic Safety Training certificates",
"Passport size photo",
].map((item) => (
<Group key={item} gap="xs">
<IconCircleCheck
size={15}
color="var(--mantine-color-teal-6)"
/>
<Text fz="sm">{item}</Text>
</Group>
))}
</Stack>
<Divider />
<SimpleGrid cols={2} spacing="xs">
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconClock size={15} color="var(--mantine-color-blue-6)" />
<div>
<Text fz="xs" c="dimmed">
Processing time
</Text>
<Text fz="sm" fw={600}>
57 working days
</Text>
</div>
</Group>
</Card>
<Card withBorder radius="sm" p="sm">
<Group gap="xs">
<IconHeart size={15} color="var(--mantine-color-red-6)" />
<div>
<Text fz="xs" c="dimmed">
Medical validity
</Text>
<Text fz="sm" fw={600}>
2 years (STCW)
</Text>
</div>
</Group>
</Card>
</SimpleGrid>
<Alert
variant="light"
color="blue"
icon={<IconInfoCircle size={15} />}
p="xs"
>
<Text fz="xs">
Application fee will be communicated during the review
process. Payment can be made online or at the EMA office.
</Text>
</Alert>
<Button
leftSection={<IconBook2 size={16} />}
onClick={() => navigate("/seaman-book/apply")}
loading={submitting}
disabled={!isEligible}
size="md"
>
Start Application
</Button>
{!isEligible && (
<Text fz="xs" c="dimmed" ta="center">
Complete all eligibility requirements to enable this button.
</Text>
)}
</Stack>
</Paper>
</SimpleGrid>
)}
{/* Info box */}
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="sm">
About the Seaman Book
</Text>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
{[
{
icon: IconBook2,
title: "Official Identity",
desc: "Internationally recognized maritime identity document required before joining any vessel.",
},
{
icon: IconFileDescription,
title: "Service Record",
desc: "Records all your sea service, vessel assignments, and employment history.",
},
{
icon: IconShield,
title: "STCW Compliance",
desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.",
},
].map(({ icon: Icon, title, desc }) => (
<Box key={title}>
<Group gap="xs" mb={4}>
<Icon size={16} color="var(--mantine-color-blue-6)" />
<Text fz="sm" fw={600}>
{title}
</Text>
</Group>
<Text fz="xs" c="dimmed" lh={1.5}>
{desc}
</Text>
</Box>
))}
</SimpleGrid>
</Paper>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
}
export default SeamanBookPage;

View File

@@ -1,485 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { AmharicDatePicker, toGregorianDateLabel } from '../../../components/AmharicDatePicker';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
NumberInput,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCamera,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconId,
IconInfoCircle,
IconShieldCheck,
IconShip,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
const STEPS = [
{ label: 'Company Information' },
{ label: 'Shipping Company Agreement' },
{ label: 'Bank Letter' },
{ label: 'Vehicle, Office & Terminal' },
{ label: 'Employees' },
{ label: 'Documents Upload' },
{ label: 'Review & Submit' },
];
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
icon: typeof IconId;
accept?: string;
}
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
{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',
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), minWidth: rem(24),
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>
);
}
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
const SlotIcon = slot.icon;
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
const DOC_SLOTS: DocSlot[] = [
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.2M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Agreement document with the shipping company', required: true, icon: IconFileDescription },
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
{ key: 'bookingClerkProfile', label: 'Booking Clerk Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
{ key: 'bookingClerkExp', label: 'Booking Clerk Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
{ key: 'canvasserProfile', label: 'Canvasser Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
{ key: 'canvasserExp', label: 'Canvasser Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
{ key: 'adminProfile', label: 'Administrative Staff Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
{ key: 'adminExp', label: 'Administrative Staff Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
{ key: 'ceoProfile', label: 'CEO/General Manager Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
{ key: 'ceoExp', label: 'CEO/General Manager Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
];
export function ShippingAgentLicenseApplicationPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
const [companyName, setCompanyName] = useState('');
const [tradeName, setTradeName] = useState('');
const [tinNumber, setTinNumber] = useState('');
const [commercialRegNumber, setCommercialRegNumber] = useState('');
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
const [businessAddress, setBusinessAddress] = useState('');
const [officeAddress, setOfficeAddress] = useState('');
const [applicantType, setApplicantType] = useState<string | null>(null);
const [ownershipType, setOwnershipType] = useState<string | null>(null);
// Step 1 — Shipping Company Agreement
const [shippingCompanyName, setShippingCompanyName] = useState('');
const [agreementRefNumber, setAgreementRefNumber] = useState('');
const [agreementStartDate, setAgreementStartDate] = useState('');
const [agreementEndDate, setAgreementEndDate] = useState('');
// Step 2 — Bank Letter
const [bankName, setBankName] = useState('');
const [accountHolderName, setAccountHolderName] = useState('');
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
// Step 3 — Vehicle, Office & Terminal
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
const [plateNumber, setPlateNumber] = useState('');
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
const [terminalName, setTerminalName] = useState('');
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
// Step 4 — Employees
const [bookingClerkName, setBookingClerkName] = useState('');
const [canvasserName, setCanvasserName] = useState('');
const [adminStaffName, setAdminStaffName] = useState('');
const [ceoName, setCeoName] = useState('');
// Step 5 — Documents
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canNext = () => {
if (active === 0) return (
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
);
if (active === 1) return (
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
!!agreementStartDate && !!agreementEndDate
);
if (active === 2) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1200000;
if (active === 3) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
if (active === 4) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!adminStaffName.trim() && !!ceoName.trim();
if (active === 5) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
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 submitTrigger({
url: '/logistics-licenses/shipping-agent',
method: 'POST',
body: {
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
businessAddress, officeAddress, applicantType, ownershipType,
shippingCompanyName, agreementRefNumber, agreementStartDate, agreementEndDate,
bankName, accountHolderName, capitalAmount,
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
bookingClerkName, canvasserName, adminStaffName, ceoName,
},
}).unwrap();
notify.success('Shipping Agent License application submitted successfully!');
navigate('/shipping-agent-license');
} catch {
notify.error('Submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
Back
</Button>
</Group>
<div>
<Title order={3}>Shipping Agent License Application</Title>
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} {STEPS[active].label}</Text>
</div>
<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>
{active === 0 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Your account email and phone number will be used automatically no need to re-enter them here.
</Alert>
<SectionHead title="Company Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 1 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Provide the details of your agreement with the shipping company you represent.
</Alert>
<SectionHead title="Shipping Company Agreement" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
<AmharicDatePicker label="Agreement Start Date" required value={agreementStartDate} onChange={setAgreementStartDate} />
<AmharicDatePicker label="Agreement End Date" required value={agreementEndDate} onChange={setAgreementEndDate} />
</SimpleGrid>
</Stack>
)}
{active === 2 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Bank letter must show a minimum capital of 1,200,000 ETB.
</Alert>
<SectionHead title="Bank Letter / Capital Evidence" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
<NumberInput
label="Capital Amount (ETB)"
required
min={0}
value={capitalAmount}
onChange={setCapitalAmount}
error={capitalAmount && Number(capitalAmount) < 1200000 ? 'Must be at least 1,200,000 ETB' : undefined}
/>
</SimpleGrid>
</Stack>
)}
{active === 3 && (
<Stack gap="md">
<SectionHead title="Vehicle Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
</SimpleGrid>
<SectionHead title="Office Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
</SimpleGrid>
<SectionHead title="Terminal Information" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
</SimpleGrid>
</Stack>
)}
{active === 4 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Four employee roles are required: Booking Clerk, Canvasser, Administrative Staff, and CEO/General Manager.
</Alert>
<SectionHead title="Employee Profiles" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
</SimpleGrid>
</Stack>
)}
{active === 5 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{DOC_SLOTS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</SimpleGrid>
</Stack>
)}
{active === 6 && (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
</Alert>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Company Name" value={companyName} />
<ReviewRow label="Trade Name" value={tradeName} />
<ReviewRow label="TIN Number" value={tinNumber} />
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
<ReviewRow label="Business Address" value={businessAddress} />
<ReviewRow label="Office Address" value={officeAddress} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Shipping Company Agreement</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
<ReviewRow label="Agreement Reference Number" value={agreementRefNumber} />
<ReviewRow label="Agreement Start Date" value={agreementStartDate ? toGregorianDateLabel(agreementStartDate) : ''} />
<ReviewRow label="Agreement End Date" value={agreementEndDate ? toGregorianDateLabel(agreementEndDate) : ''} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Bank Name" value={bankName} />
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
<ReviewRow label="Terminal Name" value={terminalName} />
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
<ReviewRow label="Canvasser" value={canvasserName} />
<ReviewRow label="Administrative Staff" value={adminStaffName} />
<ReviewRow label="CEO / General Manager" value={ceoName} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Documents</Text>
<Stack gap={6}>
{DOC_SLOTS.map((slot) => (
<Group key={slot.key} gap="xs">
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `${files[slot.key]!.name}` : '(not uploaded)'}
</Text>
</Group>
))}
</Stack>
</Paper>
</Stack>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/shipping-agent-license') : prev}>
{active === 0 ? 'Cancel' : 'Back'}
</Button>
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
) : (
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
Submit Application
</Button>
)}
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,216 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAlertCircle,
IconCertificate,
IconCheck,
IconCircleCheck,
IconClockHour4,
IconDownload,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth';
type LicenseStatus =
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
interface ShippingAgentApplication {
id: string;
companyName: string;
status: LicenseStatus;
submittedDate: string;
approvalDate: string | null;
expiryDate: string | null;
remarks: string;
}
const STATUS_COLOR: Record<string, string> = {
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
};
function RequirementItem({ label }: { label: string }) {
return (
<Group gap="xs">
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
<Text fz="sm">{label}</Text>
</Group>
);
}
export function ShippingAgentLicensePage() {
const navigate = useNavigate();
const [application, setApplication] = useState<ShippingAgentApplication | null>(null);
const [fetchTrigger] = useApiMutation<ShippingAgentApplication>();
const fetched = useRef(false);
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-licenses/shipping-agent/my', method: 'GET' })
.unwrap()
.then((data) => setApplication(data))
.catch(() => {/* no application yet */});
}, [fetchTrigger]);
return (
<Stack gap="md">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Shipping Agent License</Title>
<Text fz="sm" c="dimmed">Apply for and manage your Shipping Agent License</Text>
</div>
</Group>
{!application && (
<>
<Paper withBorder radius="lg" p="xl">
<Group gap="md" mb="lg" wrap="nowrap">
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Apply for a Shipping Agent License</Text>
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, terminal, and employee information</Text>
</div>
</Group>
<Divider mb="md" />
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
<Stack gap={6} mb="xl">
<RequirementItem label="Bank letter showing at least 1.2 million ETB" />
<RequirementItem label="Shipping company agreement" />
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
<RequirementItem label="Office title deed or office rental agreement" />
<RequirementItem label="Terminal agreement or terminal title deed" />
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff and CEO/General Manager profiles" />
<RequirementItem label="Passport-size photo for certificate printing" />
</Stack>
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/shipping-agent-license/apply')}>
Start Application
</Button>
</Paper>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
<Group gap="xs" mb={4}>
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
<Text fw={600} fz="sm" c="blue.7">About Shipping Agent Licensing</Text>
</Group>
<Text fz="sm" c="dimmed">
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
The certificate is valid for <strong>one year</strong> and must be renewed annually.
</Text>
</Paper>
</>
)}
{application && (
<>
{application.status === 'Resubmit Required' && (
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
{application.remarks || 'Please correct the requested information and resubmit.'}
</Alert>
)}
{application.status === 'Rejected' && (
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
{application.remarks || 'Your application was rejected.'}
</Alert>
)}
{application.status === 'Payment Pending' && (
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
Your application has been approved. Please pay 1000 ETB to receive your certificate.
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
</Alert>
)}
<Paper withBorder radius="lg" p="xl">
<Group justify="space-between" mb="lg">
<Group gap="sm">
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{application.companyName}</Text>
<Text fz="xs" c="dimmed">{application.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
</Group>
{application.remarks && (
<>
<Divider my="md" />
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
<Text fz="sm">{application.remarks}</Text>
</>
)}
</Paper>
{application.status !== 'Certificate Issued' && (
<Paper withBorder radius="md" p="md">
<Group gap="xs" mb="sm">
<IconClockHour4 size={16} />
<Text fw={600} fz="sm">Application Status</Text>
</Group>
<Stack gap={6}>
{[
{ label: 'Submitted', done: true },
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
<IconCheck size={12} />
</ThemeIcon>
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
</Group>
))}
</Stack>
</Paper>
)}
{application.status === 'Certificate Issued' && (
<div>
<Group gap="xs" mb="sm">
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz="md">Issued Certificate</Text>
</Group>
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
Your Shipping Agent License certificate is ready. Valid until {application.expiryDate}.
</Alert>
<Card withBorder radius="md" p="md">
<Group gap="sm" mb="xs" wrap="nowrap">
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
<div>
<Text fw={600} fz="sm">Shipping Agent License Certificate</Text>
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
</div>
</Group>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
Download Certificate
</Button>
</Card>
</div>
)}
</>
)}
</Stack>
);
}

View File

@@ -1,146 +0,0 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconCheck,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
import { FileButton } from '@mantine/core';
import { notify } from '@ema-platform/ui';
interface DocSlot {
key: string;
label: string;
description: string;
required: boolean;
}
const RENEWAL_DOCS: DocSlot[] = [
{ key: 'prevCertificate', label: 'Previous Shipping Agent Certificate', description: 'Your current/expiring certificate', required: true },
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
{ key: 'threeMonthClearance', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months', required: true },
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
{ key: 'paymentReceipt', label: 'Renewal Service Payment Receipt (600 ETB)', description: 'Proof of payment for the renewal service fee', required: true },
{ key: 'renewalDeclaration', label: 'Applicant Renewal Declaration', description: 'Signed declaration confirming renewal details', required: true },
];
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
const resetRef = useRef<() => void>(null);
return (
<Card withBorder radius="md" p="md" style={{
borderStyle: 'dashed',
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
}}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{
width: rem(44), height: rem(44), borderRadius: rem(8),
background: 'var(--mantine-color-blue-light)', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Box>
<div>
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
<Text fz="xs" c="dimmed">{slot.description}</Text>
</div>
</Group>
{file ? (
<Group gap="xs" align="center">
<IconCircleCheck size={16} 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?.(); }}>Remove</Button>
</Group>
) : (
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
);
}
export function ShippingAgentLicenseRenewalPage() {
const navigate = useNavigate();
const [submitTrigger] = useApiMutation<{ id: string }>();
const [submitting, setSubmitting] = useState(false);
const [files, setFiles] = useState<Record<string, File | null>>(
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
);
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
const handleSubmit = async () => {
setSubmitting(true);
try {
await submitTrigger({ url: '/logistics-licenses/shipping-agent/renew', method: 'POST', body: {} }).unwrap();
notify.success('Renewal request submitted successfully!');
navigate('/shipping-agent-license');
} catch {
notify.error('Renewal submission failed. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Stack gap="md">
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
Back
</Button>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
<div>
<Title order={3}>Renew Shipping Agent License</Title>
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
</div>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
A renewal service payment of 600 ETB is required. If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
</Alert>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
<Stack gap="md">
{RENEWAL_DOCS.map((slot) => (
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
))}
</Stack>
<Group justify="flex-end" mt="xl">
<Button
color="teal"
leftSection={<IconCheck size={16} />}
loading={submitting}
disabled={!canSubmit}
onClick={handleSubmit}
>
Submit Renewal Request
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -1,114 +1,21 @@
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconClock,
IconInfoCircle,
IconShip,
} from '@tabler/icons-react';
// Sample mock — in production this comes from the API
const MOCK_MY_VESSELS = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
status: 'Under Review',
submittedDate: '2024-03-15',
},
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray', 'Under Review': 'yellow', Approved: 'teal', Rejected: 'red', 'Correction Required': 'orange',
};
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselOwnerDashboardPage() {
const navigate = useNavigate();
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShip size={24} />
</ThemeIcon>
<div>
<Title order={3}>My Vessels</Title>
<Text fz="sm" c="dimmed">Manage your vessel registrations</Text>
</div>
</Group>
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
Register New Vessel
</Button>
</Group>
{MOCK_MY_VESSELS.length === 0 ? (
<Paper withBorder radius="lg" p="xl">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
<IconAnchor size={30} />
</ThemeIcon>
<Title order={4} ta="center">No Vessels Registered</Title>
<Text fz="sm" c="dimmed" ta="center" maw={400}>
You haven't registered any vessels yet. Click "Register New Vessel" to begin the application process.
</Text>
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{MOCK_MY_VESSELS.map((v) => (
<Card key={v.id} withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconShip size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{v.vesselName}</Text>
<Text fz="xs" c="dimmed">{v.id}</Text>
</div>
</Group>
<Text fz="xs" fw={600} c={`${STATUS_COLOR[v.status]}.6`}>{v.status}</Text>
</Group>
<Stack gap={4} mt="sm">
<Group gap="xs">
<Text fz="xs" c="dimmed">Category:</Text>
<Text fz="xs">{v.category}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Type:</Text>
<Text fz="xs">{v.vesselType}</Text>
</Group>
<Group gap="xs">
<Text fz="xs" c="dimmed">Submitted:</Text>
<Text fz="xs">{v.submittedDate}</Text>
</Group>
</Stack>
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
View Details
</Button>
</Card>
))}
</SimpleGrid>
)}
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
</Alert>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel owner dashboard"
description="Vessel registration is not connected to the backend yet."
/>
</Container>
);
}
export default VesselOwnerDashboardPage;

View File

@@ -1,440 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
Divider,
FileButton,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertCircle,
IconArrowRight,
IconCircleCheck,
IconFileDescription,
IconInfoCircle,
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
// Minimal vessel type for the approved vessel list
interface ApprovedVessel {
id: string;
vesselName: string;
category: string;
vesselType: string;
ownerName: string;
ownerNationalIdOrTin: string;
ownerPhone: string;
status: string;
}
const MOCK_APPROVED_VESSELS: ApprovedVessel[] = [
{
id: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
ownerName: 'Abebe Girma',
ownerNationalIdOrTin: 'ET-9812345',
ownerPhone: '+251 911 234 567',
status: 'Approved',
},
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
export interface OwnershipTransferRequest {
id: string;
vesselId: string;
vesselName: string;
category: string;
vesselType: string;
currentOwnerName: string;
currentOwnerIdOrTin: string;
currentOwnerPhone: string;
newOwnerName: string;
newOwnerIdOrTin: string;
newOwnerPhone: string;
newOwnerEmail: string;
newOwnerAddress: string;
transferReason: string;
remarks: string;
status: TransferStatus;
submittedDate: string;
approvalDate: string | null;
}
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
{
id: 'OT-2024-001',
vesselId: 'VR-2024-001',
vesselName: 'Lake Tana Star',
category: 'Inland Waterway Vessel',
vesselType: 'Passenger Ferry',
currentOwnerName: 'Abebe Girma',
currentOwnerIdOrTin: 'ET-9812345',
currentOwnerPhone: '+251 911 234 567',
newOwnerName: 'Tigist Haile',
newOwnerIdOrTin: 'ET-7743210',
newOwnerPhone: '+251 922 876 543',
newOwnerEmail: 'tigist.haile@email.com',
newOwnerAddress: 'Bahir Dar, Amhara Region',
transferReason: 'Sale',
remarks: 'Vessel sold to new owner. Bill of sale attached.',
status: 'Pending',
submittedDate: '2024-06-01',
approvalDate: null,
},
];
const TRANSFER_REASONS = [
'Sale / Purchase',
'Inheritance',
'Gift / Donation',
'Corporate Restructuring',
'Court Order',
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
};
// ---------------------------------------------------------------------------
// Transfer request card
// ---------------------------------------------------------------------------
function TransferCard({ req }: { req: OwnershipTransferRequest }) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="violet" variant="light">
<IconTransferIn size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">{req.vesselName}</Text>
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'From', value: req.currentOwnerName },
{ label: 'To', value: req.newOwnerName },
{ label: 'Reason', value: req.transferReason },
{ label: 'Submitted', value: req.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value}</Text>
</div>
))}
</SimpleGrid>
{req.status === 'Approved' && (
<Alert icon={<IconCircleCheck size={14} />} color="teal" mt="sm" py="xs">
Transfer approved on {req.approvalDate}. New certificates issued to {req.newOwnerName}.
</Alert>
)}
{req.status === 'Rejected' && req.remarks && (
<Alert icon={<IconAlertCircle size={14} />} color="red" mt="sm" py="xs">
Rejected: {req.remarks}
</Alert>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function OwnershipTransferPage() {
const navigate = useNavigate();
const [myVessels, setMyVessels] = useState<ApprovedVessel[]>([]);
const [transfers, setTransfers] = useState<OwnershipTransferRequest[]>(MOCK_TRANSFER_REQUESTS);
const [modalOpen, setModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [fetchTrigger] = useApiMutation<ApprovedVessel[]>();
const [submitTrigger] = useApiMutation<{ id: string }>();
const fetched = useRef(false);
// Form state
const [selectedVesselId, setSelectedVesselId] = useState<string | null>(null);
const [newOwnerName, setNewOwnerName] = useState('');
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
const [newOwnerPhone, setNewOwnerPhone] = useState('');
const [newOwnerEmail, setNewOwnerEmail] = useState('');
const [newOwnerAddress, setNewOwnerAddress] = useState('');
const [transferReason, setTransferReason] = useState<string | null>(null);
const [notes, setNotes] = useState('');
const [billOfSale, setBillOfSale] = useState<File | null>(null);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
.unwrap()
.then((data) => setMyVessels(Array.isArray(data) ? data : [data]))
.catch(() => {
// Fall back to mock approved vessels
setMyVessels(MOCK_APPROVED_VESSELS);
});
}, [fetchTrigger]);
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
const resetForm = () => {
setSelectedVesselId(null);
setNewOwnerName('');
setNewOwnerIdOrTin('');
setNewOwnerPhone('');
setNewOwnerEmail('');
setNewOwnerAddress('');
setTransferReason(null);
setNotes('');
setBillOfSale(null);
};
const handleSubmit = async () => {
if (!selectedVessel) return;
setSubmitting(true);
try {
await submitTrigger({
url: '/vessel-ownership-transfers',
method: 'POST',
body: {
vesselId: selectedVessel.id,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail,
newOwnerAddress, transferReason, notes,
},
}).unwrap();
// Optimistic local update
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted successfully.');
} catch {
// Still add optimistically on API error (mock mode)
const newReq: OwnershipTransferRequest = {
id: `OT-${Date.now()}`,
vesselId: selectedVessel.id,
vesselName: selectedVessel.vesselName,
category: selectedVessel.category,
vesselType: selectedVessel.vesselType,
currentOwnerName: selectedVessel.ownerName,
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
currentOwnerPhone: selectedVessel.ownerPhone,
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
transferReason: transferReason ?? '',
remarks: notes,
status: 'Pending',
submittedDate: new Date().toISOString().split('T')[0],
approvalDate: null,
};
setTransfers((prev) => [newReq, ...prev]);
resetForm();
setModalOpen(false);
notify.success('Ownership transfer request submitted.');
} finally {
setSubmitting(false);
}
};
const vesselOptions = myVessels
.filter((v) => v.status === 'Approved')
.map((v) => ({ value: v.id, label: `${v.vesselName} (${v.id})` }));
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="violet" variant="light">
<IconTransferIn size={24} />
</ThemeIcon>
<div>
<Title order={3}>Ownership Transfer</Title>
<Text fz="sm" c="dimmed">Request transfer of vessel ownership to another party</Text>
</div>
</Group>
<Button
leftSection={<IconTransferIn size={16} />}
color="violet"
onClick={() => setModalOpen(true)}
disabled={vesselOptions.length === 0}
>
Request Transfer
</Button>
</Group>
{vesselOptions.length === 0 && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
You must have at least one <strong>approved</strong> vessel registration to request an ownership transfer.{' '}
<Text span fz="sm" c="blue.6" style={{ cursor: 'pointer' }} onClick={() => navigate('/vessel-registration')}>
View my registrations
</Text>
</Alert>
)}
{/* How it works */}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-violet-light)">
<Group gap="xs" mb="sm">
<IconInfoCircle size={16} color="var(--mantine-color-violet-7)" />
<Text fw={600} fz="sm" c="violet.7">How Ownership Transfer Works</Text>
</Group>
<Stack gap={6}>
{[
"Submit a transfer request with the new owner's details and a Bill of Sale",
'The Maritime Authority reviews and verifies the transfer documents',
'Upon approval, ownership is officially transferred in the registry',
'New certificates are automatically generated for the new owner',
'The new owner receives: Certificate of Nationality, Certificate of Ownership, Certificate of Registration (sea-going) or Inland Registration Certificate (inland)',
].map((step, i) => (
<Group key={i} gap="xs" align="flex-start">
<ThemeIcon size={20} radius="xl" color="violet" variant="light" style={{ flexShrink: 0, marginTop: 2 }}>
<Text fz="xs" fw={700}>{i + 1}</Text>
</ThemeIcon>
<Text fz="sm">{step}</Text>
</Group>
))}
</Stack>
</Paper>
{/* Existing transfer requests */}
<div>
<Text fw={700} fz="sm" mb="sm">My Transfer Requests</Text>
{transfers.length === 0 ? (
<Paper withBorder radius="md" p="xl">
<Text fz="sm" c="dimmed" ta="center">No transfer requests submitted yet.</Text>
</Paper>
) : (
<Stack gap="sm">
{transfers.map((req) => <TransferCard key={req.id} req={req} />)}
</Stack>
)}
</div>
{/* Transfer request modal */}
<Modal
opened={modalOpen}
onClose={() => { setModalOpen(false); resetForm(); }}
title="Request Ownership Transfer"
size="lg"
>
<Stack gap="md">
<Alert icon={<IconAlertCircle size={15} />} color="orange" variant="light">
Ownership transfer is permanent. Ensure all details are correct before submitting.
</Alert>
<Select
label="Select Vessel"
placeholder="Choose an approved vessel"
required
data={vesselOptions}
value={selectedVesselId}
onChange={setSelectedVesselId}
/>
{selectedVessel && (
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={4}>Current Owner</Text>
<SimpleGrid cols={2} spacing="xs">
<div><Text fz="xs" c="dimmed">Name</Text><Text fz="sm">{selectedVessel.ownerName}</Text></div>
<div><Text fz="xs" c="dimmed">ID / TIN</Text><Text fz="sm">{selectedVessel.ownerNationalIdOrTin}</Text></div>
</SimpleGrid>
</Paper>
)}
<Divider label="New Owner Details" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
</SimpleGrid>
<Divider label="Supporting Document" labelPosition="center" />
{/* Bill of Sale upload */}
<Card withBorder radius="md" p="md" style={{ borderStyle: 'dashed', borderColor: billOfSale ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)' }}>
<Group gap="sm" mb="sm" wrap="nowrap">
<Box style={{ width: rem(44), height: rem(44), borderRadius: rem(8), background: 'var(--mantine-color-violet-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<IconFileDescription size={22} color="var(--mantine-color-violet-6)" />
</Box>
<div>
<Text fw={600} fz="sm">Bill of Sale / Transfer Document <Text span c="red">*</Text></Text>
<Text fz="xs" c="dimmed">Legal document confirming the transfer of ownership</Text>
</div>
</Group>
{billOfSale ? (
<Group gap="xs">
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{billOfSale.name}</Text>
<Button size="xs" variant="subtle" color="red" onClick={() => setBillOfSale(null)}>Remove</Button>
</Group>
) : (
<FileButton onChange={setBillOfSale} accept="application/pdf,image/jpeg,image/png">
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
</FileButton>
)}
</Card>
<Textarea label="Additional Notes" placeholder="Any additional information for the authority..." value={notes} onChange={(e) => setNotes(e.currentTarget.value)} rows={3} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => { setModalOpen(false); resetForm(); }}>Cancel</Button>
<Button color="violet" disabled={!canSubmit} loading={submitting} leftSection={<IconArrowRight size={15} />} onClick={handleSubmit}>
Submit Transfer Request
</Button>
</Group>
</Stack>
</Modal>
</Stack>
<Container size="lg" py="xl">
<FeatureUnavailable
title="Ownership transfer"
description="Vessel ownership transfer is not connected to the backend yet."
/>
</Container>
);
}
export default OwnershipTransferPage;

View File

@@ -27,9 +27,8 @@ import {
IconInfoCircle,
IconClockHour4,
IconTransferIn,
} from "@tabler/icons-react";
import { useApiMutation } from "@ema-platform/api";
import { authStorage } from "@ema-platform/auth";
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
// ---------------------------------------------------------------------------
// Types
@@ -186,9 +185,12 @@ export function VesselRegistrationPage() {
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
// `/vessel-registrations/my` resolves the owner from the token, so it never
// needed a profile id. Gating on one meant anyone who signed up after the
// setup wizard was removed — and so had nothing in local storage — silently
// never loaded their registration.
useEffect(() => {
const profileId = authStorage.getProfileId();
if (!profileId || fetched.current) return;
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: "/vessel-registrations/my", method: "GET" })
.unwrap()

Some files were not shown because too many files have changed in this diff Show More