mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 18:48:12 +00:00
Adding license generation functionality
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 500–3,000 GT',
|
||||
stcwRef: 'Reg. II/2 — Code A-II/2',
|
||||
level: 'Management',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-04-01',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Examination Passed',
|
||||
examDate: '2025-05-20',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
seaServiceFile: 'tekle_sea_service.pdf',
|
||||
trbFile: 'tekle_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
|
||||
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
|
||||
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
|
||||
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
|
||||
],
|
||||
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
|
||||
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
|
||||
officerRemarks: '',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'All', 'Submitted', 'Document Review', 'TRB Inspection',
|
||||
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
|
||||
'Examination Passed', 'Examination Failed', 'Certificate Issued',
|
||||
];
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,15 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||
groupSeafarer: 'የመርከበኞች አገልግሎት',
|
||||
groupVessels: 'መርከቦች',
|
||||
groupExaminations: 'ፈተናዎች',
|
||||
groupAdministration: 'አስተዳደር',
|
||||
groupAccount: 'መለያ',
|
||||
soon: 'በቅርቡ',
|
||||
details: 'ዝርዝር',
|
||||
licenceReview: 'የፈቃድ ማመልከቻዎች',
|
||||
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
|
||||
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
|
||||
@@ -13,6 +13,15 @@ export const en = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
groupLicensing: 'Licensing',
|
||||
groupSeafarer: 'Seafarer Services',
|
||||
groupVessels: 'Vessels',
|
||||
groupExaminations: 'Examinations',
|
||||
groupAdministration: 'Administration',
|
||||
groupAccount: 'Account',
|
||||
soon: 'Soon',
|
||||
details: 'Details',
|
||||
licenceReview: 'Licence Applications',
|
||||
menu: 'MENU',
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import {
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconGauge,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconMapPin,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -34,36 +35,66 @@ import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription },
|
||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
||||
{ to: '/freight-forwarder-license', label: 'nav.freightForwarderLicense', icon: IconTruck },
|
||||
{ to: '/shipping-agent-license', label: 'nav.shippingAgentLicense', icon: IconShip },
|
||||
{ to: '/combined-license', label: 'nav.combinedLicense', icon: IconStack2 },
|
||||
{ to: '/joint-investment-license', label: 'nav.jointInvestmentLicense', icon: IconUsers },
|
||||
{ to: '/mto-license', label: 'nav.mtoLicense', icon: IconTruck },
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
|
||||
{ to: '/profile', label: 'nav.profile', icon: IconUser },
|
||||
/**
|
||||
* Grouped so a reviewer can find things, and flagged so they can tell what
|
||||
* actually works: `soon` marks screens with no backend behind them yet.
|
||||
*/
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupLicensing',
|
||||
items: [
|
||||
{ to: '/licence-review', label: 'nav.licenceReview', icon: IconTruck },
|
||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupExaminations',
|
||||
items: [
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupAdministration',
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
|
||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||
{ to: '/profile', label: 'nav.profile', icon: IconUser },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||
const NAV_ITEMS: NavItem[] = NAV_SECTIONS.flatMap((section) => section.items);
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
export function BackofficeLayout() {
|
||||
@@ -87,13 +118,28 @@ export function BackofficeLayout() {
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
// Label each crumb from the nav item it corresponds to, falling back to a
|
||||
// readable form of the path segment. Every crumb was previously labelled
|
||||
// "Dashboard", which made the trail useless.
|
||||
const crumbs = [
|
||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
||||
...segments
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => path !== '/dashboard')
|
||||
.filter((path) => !path.startsWith('/um'))
|
||||
.map((path) => ({ label: t('nav.dashboard'), path })),
|
||||
.map((path) => {
|
||||
const match = NAV_ITEMS.find((item) => item.to === path);
|
||||
if (match) return { label: t(match.label), path };
|
||||
const segment = path.split('/').pop() ?? '';
|
||||
// Ids get a generic label rather than a raw uuid in the trail.
|
||||
const isId = /^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment);
|
||||
return {
|
||||
label: isId
|
||||
? t('nav.details', 'Details')
|
||||
: segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
path,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const go = (item: NavItem) => {
|
||||
@@ -164,7 +210,9 @@ export function BackofficeLayout() {
|
||||
<button
|
||||
key={item.to}
|
||||
onClick={() => go(item)}
|
||||
title={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : undefined}
|
||||
style={{
|
||||
opacity: item.soon ? 0.55 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(6),
|
||||
@@ -214,7 +262,7 @@ export function BackofficeLayout() {
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={NAV_ITEMS}
|
||||
navItems={NAV_SECTIONS}
|
||||
collapsed={collapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import { AuthBootstrap, AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { i18n } from '../i18n/config';
|
||||
@@ -27,7 +27,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
</I18nextProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -37,16 +37,8 @@ import { VesselRegistrationFormBuilderPage } from '../features/vessel-registrati
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { FreightForwarderLicenseQueuePage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
|
||||
import { FreightForwarderLicenseReviewPage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseReviewPage';
|
||||
import { ShippingAgentLicenseQueuePage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
|
||||
import { ShippingAgentLicenseReviewPage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseReviewPage';
|
||||
import { CombinedLicenseQueuePage } from '../features/combined-license/pages/CombinedLicenseQueuePage';
|
||||
import { CombinedLicenseReviewPage } from '../features/combined-license/pages/CombinedLicenseReviewPage';
|
||||
import { JointInvestmentLicenseQueuePage } from '../features/joint-investment-license/pages/JointInvestmentLicenseQueuePage';
|
||||
import { JointInvestmentLicenseReviewPage } from '../features/joint-investment-license/pages/JointInvestmentLicenseReviewPage';
|
||||
import { MtoLicenseQueuePage } from '../features/mto-license/pages/MtoLicenseQueuePage';
|
||||
import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseReviewPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
@@ -96,16 +88,19 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
{ path: 'freight-forwarder-license', element: <FreightForwarderLicenseQueuePage /> },
|
||||
{ path: 'freight-forwarder-license/:id', element: <FreightForwarderLicenseReviewPage /> },
|
||||
{ path: 'shipping-agent-license', element: <ShippingAgentLicenseQueuePage /> },
|
||||
{ path: 'shipping-agent-license/:id', element: <ShippingAgentLicenseReviewPage /> },
|
||||
{ path: 'combined-license', element: <CombinedLicenseQueuePage /> },
|
||||
{ path: 'combined-license/:id', element: <CombinedLicenseReviewPage /> },
|
||||
{ path: 'joint-investment-license', element: <JointInvestmentLicenseQueuePage /> },
|
||||
{ path: 'joint-investment-license/:id', element: <JointInvestmentLicenseReviewPage /> },
|
||||
{ path: 'mto-license', element: <MtoLicenseQueuePage /> },
|
||||
{ path: 'mto-license/:id', element: <MtoLicenseReviewPage /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
|
||||
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'shipping-agent-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'combined-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'combined-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'joint-investment-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'joint-investment-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'waiver', element: <WaiverQueuePage /> },
|
||||
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
|
||||
],
|
||||
|
||||
@@ -13,7 +13,9 @@ const loadPreferences = (): PreferencesState => {
|
||||
const stored = localStorage.getItem(PREFERENCES_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {}
|
||||
return { layoutMode: 'top' };
|
||||
// The sidebar is the grouped, scannable layout; the top strip puts all
|
||||
// ~20 destinations in one horizontally-scrolling row.
|
||||
return { layoutMode: 'sidebar' };
|
||||
};
|
||||
|
||||
const savePreferences = (state: PreferencesState) => {
|
||||
|
||||
Reference in New Issue
Block a user