Adding license generation functionality

This commit is contained in:
Muluhabt
2026-07-29 16:53:50 +03:00
parent 26cc1f4fd2
commit 5161e278ce
102 changed files with 5995 additions and 21326 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

@@ -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: IconStamp },
{ label: 'Awaiting Review', value: stats.pending, color: 'yellow', icon: IconClock },
{ label: 'Endorsed', value: stats.endorsed, color: 'teal', icon: IconCircleCheck },
{ label: 'Rejected', value: stats.rejected, color: 'red', icon: IconShieldCheck },
].map(({ label, value, color, icon: Icon }) => (
<Card key={label} withBorder radius="md" p="sm">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
</Group>
</Card>
))}
</SimpleGrid>
{/* Table */}
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Endorsement Applications</Text>
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search by name, App ID or Seafarer ID…"
leftSection={<IconSearch size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
size="sm"
style={{ minWidth: rem(280) }}
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
/>
<Select
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => setStatusFilter(v ?? 'All')}
size="sm"
style={{ width: rem(200) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconStamp size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No applications found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['App ID', 'Seafarer', 'Foreign CoC Type', 'Issued By', 'Country', 'Expiry', 'Payment', 'Status', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
<Table.Td>
<Text fz="xs" fw={500}>{app.name}</Text>
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
</Table.Td>
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocType}</Text></Table.Td>
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocIssuer}</Text></Table.Td>
<Table.Td><Text fz="xs">{app.foreignCocCountry}</Text></Table.Td>
<Table.Td><Text fz="xs">{app.foreignCocExpiryDate}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
{app.paymentStatus}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
</Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/endorsement-queue/${app.id}`)}>
Review
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{filtered.length > 0 && (
<Group px="md" py="sm" justify="space-between">
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_ENDORSEMENT_APPS.length} applications</Text>
</Group>
)}
</Paper>
</Stack>
<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"><IconStamp size={17} /><Text fw={700}>Officer Action</Text></Group>}
size="md"
radius="lg"
>
<Stack gap="md">
{actionOptions.length === 0 ? (
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
<Text fz="sm" fw={600}>No further actions available.</Text>
</Alert>
) : (
<>
<Select
label="Action"
placeholder="Select an action…"
data={actionOptions}
value={action}
onChange={setAction}
size="sm"
/>
{isEndorse && (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} p="xs">
<Text fz="xs">The endorsement is co-terminous with the foreign CoC. Expiry date must not exceed the foreign CoC expiry ({app.foreignCocExpiryDate}).</Text>
</Alert>
<TextInput
label="Endorsement Number"
placeholder="EMA-END-2025-XXX"
value={endNo}
onChange={(e) => setEndNo(e.currentTarget.value)}
size="sm"
required
/>
<SimpleGrid cols={2} spacing="sm">
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" required />
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} size="sm" required />
</SimpleGrid>
</>
)}
<Textarea
label="Remarks / Notes (sent to applicant)"
placeholder="Verification findings, reason for rejection, or additional instructions…"
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
size="sm"
/>
{isDestructive && (
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
<Text fz="xs" fw={600}>This will permanently reject the application. The applicant must submit a new application.</Text>
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
leftSection={<IconCheck size={14} />}
disabled={!action}
color={isDestructive ? 'red' : isEndorse ? 'teal' : 'blue'}
onClick={handleConfirm}
>
Confirm
</Button>
</Group>
</>
)}
</Stack>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Main review page
// ---------------------------------------------------------------------------
/**
* 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={<IconStamp size={15} />} onClick={() => setActionModalOpen(true)}>
Take Action
</Button>
</Group>
</Paper>
)}
{isTerminal && (
<Alert
variant="light"
color={app.status === 'Endorsed' ? 'teal' : 'red'}
icon={app.status === 'Endorsed' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
<Text fz="sm" fw={600}>
{app.status === 'Endorsed' && `Endorsement ${app.endorsementNumber} issued on ${app.endorsementIssueDate}.`}
{app.status === 'Rejected' && 'Application rejected. Applicant must submit a new application.'}
</Text>
</Alert>
)}
<WorkflowBar status={app.status} />
<Tabs defaultValue="applicant" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="applicant" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
<Tabs.Tab value="foreign-coc" leftSection={<IconShieldCheck size={16} />}>Foreign CoC</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFileDescription size={16} />}>Documents</Tabs.Tab>
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
<Tabs.Tab value="endorsement" leftSection={<IconStamp size={16} />}>Endorsement</Tabs.Tab>
</Tabs.List>
{/* ── Applicant ─────────────────────────────────────────── */}
<Tabs.Panel value="applicant">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">{app.name}</Text>
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
</div>
</Group>
<Divider mb="md" />
<SectionLabel>Personal Information</SectionLabel>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Email" value={app.email} />
<InfoRow label="Mobile" value={app.mobile} />
<InfoRow label="Nationality" value={app.nationality} />
<InfoRow label="Submitted" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Foreign CoC ───────────────────────────────────────── */}
<Tabs.Panel value="foreign-coc">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="blue" radius="lg"><IconShieldCheck size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Foreign Certificate of Competency</Text>
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
</div>
</Group>
<Divider mb="md" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
<InfoRow label="CoC Number" value={app.foreignCocNumber} />
<InfoRow label="Certificate Type" value={app.foreignCocType} />
<InfoRow label="Issuing Authority" value={app.foreignCocIssuer} />
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
<InfoRow label="Issue Date" value={app.foreignCocIssueDate} />
<InfoRow label="Expiry Date" value={app.foreignCocExpiryDate} />
<InfoRow label="STCW Reference" value={app.stcwRef} />
</SimpleGrid>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="sm">
<Text fz="xs">
<strong>Verification checklist (STCW Reg I/10):</strong> Confirm the issuing state is an STCW party,
the certificate is authentic, valid, and not suspended or cancelled, and the seafarer holds the
required sea service and medical fitness.
</Text>
</Alert>
<Box maw={360} mt="md">
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC" />
</Box>
</Paper>
</Tabs.Panel>
{/* ── Supporting Documents ──────────────────────────────── */}
<Tabs.Panel value="documents">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
Verify all supporting documents. A certified translation is required if the foreign CoC is not in English.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC (Original or Certified Copy)" />
<DocViewer fileName={app.certifiedTranslationFile} label="Certified Translation (if not English)" />
<DocViewer fileName={app.seamanBookFile} label="Ethiopian Seaman Book" />
<DocViewer fileName={app.medicalFile} label="Valid Medical Fitness Certificate (ENG I/2)" />
<DocViewer fileName={app.photoFile} label="Passport-Size Photo" />
</SimpleGrid>
</Stack>
</Tabs.Panel>
{/* ── Payment ───────────────────────────────────────────── */}
<Tabs.Panel value="payment">
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Payment</Text>
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>{app.paymentStatus}</Badge>
</div>
</Group>
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
<Text fw={700} fz="sm" mb="md">Endorsement Fee Breakdown</Text>
{[
{ label: 'Application Processing Fee', amount: 300 },
{ label: 'Document Verification Fee', amount: 200 },
{ label: 'Endorsement Issuance Fee', amount: 500 },
].map(({ label, amount }) => (
<Group key={label} justify="space-between" mb={4}>
<Text fz="sm">{label}</Text>
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
</Group>
))}
<Divider mt="sm" mb="xs" />
<Group justify="space-between">
<Text fz="sm" fw={800}>Total</Text>
<Text fz="md" fw={800} c="blue">ETB 1,000.00</Text>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
<InfoRow label="Transaction Reference" value="CBE-TXN-20250405-088" />
<InfoRow label="Payment Date" value={app.submitted} />
</SimpleGrid>
</Paper>
</Tabs.Panel>
{/* ── Endorsement ───────────────────────────────────────── */}
<Tabs.Panel value="endorsement">
<Stack gap="md">
{app.status === 'Endorsed' ? (
<>
<Alert variant="light" color="teal" icon={<IconCircleCheck size={17} />}>
Endorsement has been issued. The seafarer has been notified.
</Alert>
<Paper withBorder radius="lg" p="lg">
<Group gap="sm" mb="lg">
<ThemeIcon size="xl" variant="light" color="teal" radius="lg"><IconStamp size={22} stroke={1.5} /></ThemeIcon>
<div>
<Text fw={700} fz="lg">Issued Endorsement</Text>
<Text fz="xs" c="dimmed">STCW Reg I/10 Flag State Endorsement</Text>
</div>
</Group>
<Divider mb="md" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<InfoRow label="Endorsement Number" value={app.endorsementNumber} />
<InfoRow label="Issue Date" value={app.endorsementIssueDate} />
<InfoRow label="Expiry Date" value={app.endorsementExpiryDate} />
<InfoRow label="Certificate Type" value={app.foreignCocType} />
<InfoRow label="Original CoC No." value={app.foreignCocNumber} />
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
</SimpleGrid>
{app.officerRemarks && (
<>
<Divider mt="md" mb="md" />
<SectionLabel>Officer Remarks</SectionLabel>
<Text fz="sm">{app.officerRemarks}</Text>
</>
)}
</Paper>
</>
) : app.status === 'Rejected' ? (
<Paper withBorder radius="lg" p="lg">
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />} mb="md">
Application rejected. No endorsement was issued.
</Alert>
{app.officerRemarks && (
<>
<SectionLabel>Rejection Reason</SectionLabel>
<Text fz="sm">{app.officerRemarks}</Text>
</>
)}
</Paper>
) : (
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
Endorsement will be issued here once all documents are verified and the application is approved.
The endorsement is co-terminous with the foreign CoC (expires {app.foreignCocExpiryDate}).
</Alert>
)}
</Stack>
</Tabs.Panel>
</Tabs>
<ActionModal
app={app}
opened={actionModalOpen}
onClose={() => setActionModalOpen(false)}
onAction={handleAction}
<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,172 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
SegmentedControl,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useClaimApplicationMutation,
useGetAssignedToMeQuery,
useGetQueueQuery,
} from '@ema-platform/api';
/**
* The officer work pool.
*
* "Unclaimed" is the shared queue; claiming moves an application into "Mine"
* and it stays there through every adjustment round.
*/
export function LicenseQueuePage() {
const navigate = useNavigate();
const [tab, setTab] = useState<'unclaimed' | 'mine'>('unclaimed');
const [search, setSearch] = useState('');
const queue = useGetQueueQuery({ search: search || undefined });
const mine = useGetAssignedToMeQuery({ search: search || undefined });
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
const active = tab === 'unclaimed' ? queue : mine;
const items = active.data?.items ?? [];
async function handleClaim(id: string) {
try {
await claim(id).unwrap();
notifications.show({
color: 'teal',
title: 'Claimed',
message: 'The application is now assigned to you.',
});
setTab('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: 'Could not claim',
message: extractErrorMessage(err, 'Another officer already claimed it.'),
});
queue.refetch();
}
}
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<Title order={3}>Licence applications</Title>
<Group>
<TextInput
placeholder="Company, TIN or number"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={260}
/>
<SegmentedControl
value={tab}
onChange={(v) => setTab(v as 'unclaimed' | 'mine')}
data={[
{ label: `Unclaimed (${queue.data?.total ?? 0})`, value: 'unclaimed' },
{ label: `Mine (${mine.data?.total ?? 0})`, value: 'mine' },
]}
/>
</Group>
</Group>
<Card withBorder padding={0}>
{active.isLoading ? (
<Center h={200}>
<Loader />
</Center>
) : items.length === 0 ? (
<Center h={160}>
<Text c="dimmed" size="sm">
{tab === 'unclaimed'
? 'No applications waiting to be claimed.'
: 'You have no applications in progress.'}
</Text>
</Center>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Number</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>TIN</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>
<Text size="sm" c="dimmed">
{app.tinNumber ?? '—'}
</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">
{tab === 'unclaimed' ? (
<Button
size="xs"
loading={claiming}
onClick={() => handleClaim(app.id)}
>
Claim
</Button>
) : (
<Button
size="xs"
variant="light"
onClick={() => navigate(`/licence-review/${app.id}`)}
>
Review
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
</Container>
);
}
export default LicenseQueuePage;

View File

@@ -0,0 +1,600 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Center,
Checkbox,
Container,
Grid,
Group,
Loader,
Modal,
NumberInput,
Paper,
Stack,
Table,
Text,
Textarea,
TextInput,
Timeline,
Title,
} from '@mantine/core';
import { IconAlertTriangle, IconClipboardCheck } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import {
STATUS_COLORS,
STATUS_LABELS,
extractErrorMessage,
useApproveDocumentsMutation,
useCompleteReviewMutation,
useFinalApproveMutation,
useGetApplicationForReviewQuery,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useRejectApplicationMutation,
useRequestAdjustmentMutation,
useScheduleInspectionMutation,
type RemarkTargetType,
} from '@ema-platform/api';
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
/**
* The officer's review workspace: the submitted data, the documents, and the
* decision actions valid for the application's current state.
*/
export function LicenseReviewPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const { data, isLoading, refetch } = useGetApplicationForReviewQuery(id, { skip: !id });
const { data: inspections = [], refetch: refetchInspections } = useGetInspectionsQuery(id, {
skip: !id,
});
const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation();
const [approveDocuments] = useApproveDocumentsMutation();
const [finalApprove] = useFinalApproveMutation();
const [rejectApplication] = useRejectApplicationMutation();
const [scheduleInspection] = useScheduleInspectionMutation();
const [recordResult] = useRecordInspectionResultMutation();
const [flags, setFlags] = useState<FlagMap>({});
const [adjustOpen, setAdjustOpen] = useState(false);
const [generalRemark, setGeneralRemark] = useState('');
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [capital, setCapital] = useState<number | undefined>();
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]);
if (isLoading || !data) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
const app = data.application;
const status = app.status;
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: 'Action failed',
message: extractErrorMessage(err),
});
}
}
function toggleFlag(targetType: RemarkTargetType, key: string) {
setFlags((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { targetType, remark: '' };
return next;
});
}
const flagged = Object.entries(flags);
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
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">
round {app.adjustmentRound}
</Badge>
)}
</Group>
</div>
<Button variant="subtle" onClick={() => navigate('/licence-review')}>
Back to queue
</Button>
</Group>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Stack>
{/* Submitted answers, with a checkbox per section to flag it. */}
{Object.entries(app.formData ?? {}).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="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="What must the applicant correct?"
value={flags[sectionKey].remark}
onChange={(e) =>
setFlags((p) => ({
...p,
[sectionKey]: { ...p[sectionKey], remark: e.currentTarget.value },
}))
}
/>
)}
</Card>
))}
<Card withBorder padding="md">
<Text fw={600} size="sm" mb="xs">
Documents ({data.attachments.length})
</Text>
<Stack gap="xs">
{data.attachments.map((attachment) => (
<Group key={attachment.id} justify="space-between" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text size="sm">{attachment.documentKey}</Text>
<Text size="xs" c="dimmed" truncate>
{attachment.files?.[0]?.originalName}
</Text>
</div>
<Group gap="xs" wrap="nowrap">
{attachment.files?.[0]?.url && (
<Button
size="compact-xs"
variant="subtle"
component="a"
href={attachment.files[0].url}
target="_blank"
>
Open
</Button>
)}
<Checkbox
size="xs"
checked={Boolean(flags[attachment.documentKey])}
onChange={() => toggleFlag('DOCUMENT', attachment.documentKey)}
label="Flag"
/>
</Group>
</Group>
))}
{flagged
.filter(([, f]) => f.targetType === 'DOCUMENT')
.map(([key, f]) => (
<TextInput
key={key}
size="xs"
placeholder={`Why must "${key}" be corrected?`}
value={f.remark}
onChange={(e) =>
setFlags((p) => ({
...p,
[key]: { ...p[key], remark: e.currentTarget.value },
}))
}
/>
))}
</Stack>
</Card>
<Card withBorder padding="md">
<Text fw={600} size="sm" mb="xs">
Staff ({data.staff.length})
</Text>
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Role</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>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>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack>
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
Decision
</Text>
<Stack gap="xs">
{/* Capital is checked at approval, so it must be capturable at
every review stage — not only during the first one. */}
{needsCapital && (
<>
<NumberInput
label="Verified capital (ETB)"
description={
threshold
? `Minimum ${threshold.toLocaleString()} — check against the bank letter`
: 'Checked against the bank letter'
}
value={capital ?? ''}
onChange={(v) => setCapital(Number(v) || undefined)}
thousandSeparator=","
error={
capital !== undefined && threshold && capital < threshold
? `Below the ${threshold.toLocaleString()} minimum`
: undefined
}
/>
<Text size="xs" c="dimmed" mt={-4} mb={4}>
Applicant declared{' '}
{app.capitalAmountDeclared
? Number(app.capitalAmountDeclared).toLocaleString()
: '—'}
{app.capitalAmountVerified
? ` · verified ${Number(app.capitalAmountVerified).toLocaleString()}`
: ' · not verified yet'}
</Text>
</>
)}
{status === 'UNDER_REVIEW' && (
<Button
onClick={() =>
run(
() =>
completeReview({
id,
capitalAmountVerified: capital,
}).unwrap(),
'Review completed',
)
}
>
Complete review
</Button>
)}
{status === 'UNDER_EVALUATION' && (
<Button
onClick={() =>
run(() => approveDocuments({ id }).unwrap(), 'Documents approved')
}
>
Approve documents
</Button>
)}
{status === 'INSPECTION_PENDING' && (
<>
{pendingInspection ? (
<Button
leftSection={<IconClipboardCheck size={16} />}
onClick={() => setResultOpen(true)}
>
Record inspection result
</Button>
) : (
<Button onClick={() => setInspectionOpen(true)}>
Schedule inspection
</Button>
)}
</>
)}
{(status === 'INSPECTION_COMPLETED' ||
status === 'UNDER_EVALUATION') && (
<Button
color="teal"
disabled={Boolean(threshold) && !capital}
onClick={() =>
run(
() =>
finalApprove({
id,
capitalAmountVerified: capital,
}).unwrap(),
'Approved',
)
}
>
Final approve
</Button>
)}
{['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'].includes(
status,
) && (
<>
<Button
color="orange"
variant="light"
disabled={flagged.length === 0}
onClick={() => setAdjustOpen(true)}
>
Request adjustment ({flagged.length})
</Button>
<Button color="red" variant="subtle" onClick={() => setRejectOpen(true)}>
Reject
</Button>
</>
)}
{status === 'PAYMENT_PENDING' && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
Waiting for the applicant to pay {app.feeAmount} {app.feeCurrency}.
</Alert>
)}
</Stack>
</Paper>
<Paper withBorder p="md">
<Text fw={600} size="sm" mb="sm">
History
</Text>
<Timeline bulletSize={14} lineWidth={2}>
{data.history.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">
{entry.actorName ?? 'System'} ·{' '}
{new Date(entry.createdAt).toLocaleString()}
</Text>
{entry.remark && (
<Text size="xs" mt={2}>
{entry.remark}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
</Paper>
</Stack>
</Grid.Col>
</Grid>
<Modal opened={adjustOpen} onClose={() => setAdjustOpen(false)} title="Request adjustment">
<Stack>
<Textarea
label="Overall note"
value={generalRemark}
onChange={(e) => setGeneralRemark(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed">
{flagged.length} item(s) flagged. The applicant will only be able to change these.
</Text>
<Button
color="orange"
disabled={flagged.some(([, f]) => !f.remark.trim())}
onClick={() =>
run(async () => {
await requestAdjustment({
id,
generalRemark,
items: flagged.map(([key, f]) => ({
targetType: f.targetType,
targetKey: key,
remark: f.remark,
})),
}).unwrap();
setFlags({});
setGeneralRemark('');
setAdjustOpen(false);
}, 'Adjustment requested')
}
>
Send back to applicant
</Button>
</Stack>
</Modal>
<Modal opened={rejectOpen} onClose={() => setRejectOpen(false)} title="Reject application">
<Stack>
<Textarea
label="Reason"
withAsterisk
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Button
color="red"
disabled={!rejectReason.trim()}
onClick={() =>
run(async () => {
await rejectApplication({ id, reason: rejectReason }).unwrap();
setRejectOpen(false);
}, 'Application rejected')
}
>
Reject
</Button>
</Stack>
</Modal>
<Modal
opened={inspectionOpen}
onClose={() => setInspectionOpen(false)}
title="Schedule inspection"
>
<Stack>
<TextInput
type="datetime-local"
label="Date and time"
value={inspectionDate}
onChange={(e) => setInspectionDate(e.currentTarget.value)}
/>
<Button
disabled={!inspectionDate}
onClick={() =>
run(async () => {
await scheduleInspection({
applicationId: id,
scheduledDate: new Date(inspectionDate).toISOString(),
}).unwrap();
setInspectionOpen(false);
}, 'Inspection scheduled')
}
>
Schedule
</Button>
</Stack>
</Modal>
<Modal opened={resultOpen} onClose={() => setResultOpen(false)} title="Inspection result">
<Stack>
<Textarea
label="Findings"
withAsterisk
value={findings}
onChange={(e) => setFindings(e.currentTarget.value)}
/>
<Group grow>
<Button
color="teal"
disabled={!findings.trim() || !pendingInspection}
onClick={() =>
run(async () => {
await recordResult({
inspectionId: pendingInspection!.id,
applicationId: id,
result: 'PASSED',
findings,
}).unwrap();
setResultOpen(false);
}, 'Inspection passed')
}
>
Passed
</Button>
<Button
color="red"
variant="light"
disabled={!findings.trim() || !pendingInspection}
onClick={() =>
run(async () => {
await recordResult({
inspectionId: pendingInspection!.id,
applicationId: id,
result: 'FAILED',
findings,
}).unwrap();
setResultOpen(false);
}, 'Inspection failed')
}
>
Failed
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default LicenseReviewPage;

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;