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

This commit is contained in:
Estifo77
2026-07-29 09:40:33 +03:00
58 changed files with 14444 additions and 875 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,183 @@
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Button,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
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>
);
}
export function LogisticsHeadDashboardPage() {
const navigate = useNavigate();
const allApps = LICENSE_LINES.flatMap((line) =>
line.apps.map((a) => ({ ...a, _line: line.label, _route: line.route }))
);
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 recent = [...allApps]
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
.slice(0, 6);
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>
<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>
<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.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>
</Grid>
</Stack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,165 @@
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>
);
}
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>
);
}

View File

@@ -0,0 +1,415 @@
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',
];
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>
);
}

View File

@@ -0,0 +1,334 @@
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>
);
}
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>
);
}

View File

@@ -0,0 +1,527 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Checkbox,
Divider,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Switch,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconEdit,
IconFilePlus,
IconGripVertical,
IconPlus,
IconSettings,
IconTrash,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FieldType = 'text' | 'number' | 'select' | 'textarea' | 'date' | 'file';
type FieldStep = 'Vessel Details' | 'Technical & Ownership' | 'Documents';
interface FormField {
id: string;
label: string;
key: string;
type: FieldType;
step: FieldStep;
required: boolean;
placeholder: string;
helpText: string;
options: string; // comma-separated for select type
enabled: boolean;
system: boolean; // system fields cannot be deleted
}
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text Input' },
{ value: 'number', label: 'Number Input' },
{ value: 'select', label: 'Dropdown / Select' },
{ value: 'textarea', label: 'Text Area' },
{ value: 'date', label: 'Date Picker' },
{ value: 'file', label: 'File Upload' },
];
const STEPS: FieldStep[] = ['Vessel Details', 'Technical & Ownership', 'Documents'];
const TYPE_COLOR: Record<FieldType, string> = {
text: 'blue',
number: 'orange',
select: 'violet',
textarea: 'gray',
date: 'teal',
file: 'green',
};
// ---------------------------------------------------------------------------
// Default form schema — mirrors the vessel registration wizard
// ---------------------------------------------------------------------------
const DEFAULT_FIELDS: FormField[] = [
// Vessel Details
{ id: 'f1', label: 'Vessel Name', key: 'vesselName', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Lake Tana Star', helpText: '', options: '', enabled: true, system: true },
{ id: 'f2', label: 'Vessel Type', key: 'vesselType', type: 'select', step: 'Vessel Details', required: true, placeholder: 'Select type', helpText: 'Options depend on selected category', options: 'Passenger Ferry,Cargo Barge,Fishing Vessel,Tug Boat,Container Ship,Bulk Carrier,Tanker', enabled: true, system: true },
{ id: 'f3', label: 'Registration Area', key: 'registrationArea', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Bahir Dar', helpText: 'Port or lake authority area', options: '', enabled: true, system: true },
{ id: 'f4', label: 'Flag State', key: 'flagState', type: 'text', step: 'Vessel Details', required: true, placeholder: 'e.g. Ethiopia', helpText: '', options: '', enabled: true, system: true },
{ id: 'f5', label: 'Passenger Capacity / Gross Tonnage',key: 'capacityValue', type: 'number', step: 'Vessel Details', required: true, placeholder: 'Enter value', helpText: 'Passenger count for ferries; GT for cargo', options: '', enabled: true, system: true },
{ id: 'f6', label: 'Vessel Length (m)', key: 'vesselLengthM', type: 'number', step: 'Vessel Details', required: true, placeholder: 'e.g. 32', helpText: '', options: '', enabled: true, system: false },
// Technical & Ownership
{ id: 'f7', label: 'IMO / Hull Number', key: 'imoOrHullNumber', type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. IMO9876543', helpText: '', options: '', enabled: true, system: true },
{ id: 'f8', label: 'Manufacturer / Shipyard', key: 'manufacturerShipyard',type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. Hyundai Heavy Industries', helpText: '', options: '', enabled: true, system: true },
{ id: 'f9', label: 'Year Built', key: 'yearBuilt', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 2015', helpText: '', options: '', enabled: true, system: true },
{ id: 'f10', label: 'Engine Type', key: 'engineType', type: 'select', step: 'Technical & Ownership',required: true, placeholder: 'Select engine type', helpText: '', options: 'Diesel Engine,Dual-Fuel Engine,Electric Motor,Hybrid Diesel-Electric,Steam Turbine,Gas Turbine,Outboard Motor', enabled: true, system: true },
{ id: 'f11', label: 'Engine Power (kW)', key: 'enginePowerKw', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 450', helpText: '', options: '', enabled: true, system: false },
{ id: 'f12', label: 'Number of Engines', key: 'numberOfEngines', type: 'number', step: 'Technical & Ownership',required: true, placeholder: 'e.g. 2', helpText: '', options: '', enabled: true, system: false },
{ id: 'f13', label: 'Hull Material', key: 'hullMaterial', type: 'select', step: 'Technical & Ownership',required: true, placeholder: 'Select material', helpText: '', options: 'Steel,Aluminum,Fiberglass/GRP,Wood,Ferro-Cement', enabled: true, system: false },
{ id: 'f14', label: 'Owner Full Name', key: 'ownerName', type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'Legal name of the owner', helpText: '', options: '', enabled: true, system: true },
{ id: 'f15', label: 'National ID / TIN', key: 'ownerNationalIdOrTin',type: 'text', step: 'Technical & Ownership',required: true, placeholder: 'e.g. ET-0000000', helpText: '', options: '', enabled: true, system: true },
{ id: 'f16', label: 'Owner Phone', key: 'ownerPhone', type: 'text', step: 'Technical & Ownership',required: true, placeholder: '+251 9XX XXX XXX', helpText: '', options: '', enabled: true, system: true },
{ id: 'f17', label: 'Owner Address', key: 'ownerAddress', type: 'textarea', step: 'Technical & Ownership',required: false, placeholder: 'City, Region', helpText: '', options: '', enabled: true, system: false },
// Documents
{ id: 'f18', label: 'Vessel Photos (min. 2)', key: 'vesselPhotos', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Required for all vessel types', options: '', enabled: true, system: true },
{ id: 'f19', label: 'Proof of Ownership / Bill of Sale', key: 'proofOfOwnership', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
{ id: 'f20', label: 'Ship Particulars', key: 'shipParticulars', type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
{ id: 'f21', label: 'Insurance Certificate', key: 'insuranceCertificate',type: 'file', step: 'Documents', required: true, placeholder: '', helpText: 'Sea-going vessels only', options: '', enabled: true, system: false },
];
function generateId() {
return 'f' + Date.now();
}
// ---------------------------------------------------------------------------
// Field row card
// ---------------------------------------------------------------------------
function FieldCard({
field,
index,
total,
onEdit,
onDelete,
onToggle,
onMoveUp,
onMoveDown,
}: {
field: FormField;
index: number;
total: number;
onEdit: () => void;
onDelete: () => void;
onToggle: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}) {
return (
<Card
withBorder
radius="sm"
p="sm"
style={{
opacity: field.enabled ? 1 : 0.5,
borderColor: field.enabled ? 'var(--mantine-color-default-border)' : 'var(--mantine-color-gray-3)',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon variant="subtle" color="gray" size="sm" style={{ cursor: 'grab', flexShrink: 0 }}>
<IconGripVertical size={14} />
</ActionIcon>
<div style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text fz="sm" fw={600} truncate>{field.label}</Text>
{field.required && <Text fz="xs" c="red" fw={700}>*</Text>}
{field.system && <Badge size="xs" color="gray" variant="outline">system</Badge>}
</Group>
<Group gap="xs" mt={2}>
<Badge size="xs" color={TYPE_COLOR[field.type]} variant="light">{field.type}</Badge>
<Text fz="xs" c="dimmed" truncate>key: {field.key}</Text>
{field.helpText && <Text fz="xs" c="dimmed" truncate>· {field.helpText}</Text>}
</Group>
</div>
</Group>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<Switch
size="xs"
checked={field.enabled}
onChange={onToggle}
title={field.enabled ? 'Disable field' : 'Enable field'}
/>
<ActionIcon variant="subtle" color="gray" size="sm" disabled={index === 0} onClick={onMoveUp}>
<IconArrowUp size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" size="sm" disabled={index === total - 1} onClick={onMoveDown}>
<IconArrowDown size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="blue" size="sm" onClick={onEdit}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
disabled={field.system}
onClick={onDelete}
title={field.system ? 'System fields cannot be deleted' : 'Delete field'}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function VesselRegistrationFormBuilderPage() {
const [fields, setFields] = useState<FormField[]>(DEFAULT_FIELDS);
const [activeStep, setActiveStep] = useState<FieldStep>('Vessel Details');
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<FormField | null>(null);
const [isNew, setIsNew] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FormField | null>(null);
const [saving, setSaving] = useState(false);
// Draft state for the editor drawer
const [draftLabel, setDraftLabel] = useState('');
const [draftKey, setDraftKey] = useState('');
const [draftType, setDraftType] = useState<FieldType>('text');
const [draftStep, setDraftStep] = useState<FieldStep>('Vessel Details');
const [draftRequired, setDraftRequired] = useState(false);
const [draftPlaceholder, setDraftPlaceholder] = useState('');
const [draftHelpText, setDraftHelpText] = useState('');
const [draftOptions, setDraftOptions] = useState('');
const stepFields = fields.filter((f) => f.step === activeStep);
const totalEnabled = fields.filter((f) => f.enabled).length;
const openEdit = (field: FormField) => {
setEditing(field);
setIsNew(false);
setDraftLabel(field.label);
setDraftKey(field.key);
setDraftType(field.type);
setDraftStep(field.step);
setDraftRequired(field.required);
setDraftPlaceholder(field.placeholder);
setDraftHelpText(field.helpText);
setDraftOptions(field.options);
setDrawerOpen(true);
};
const openNew = () => {
setEditing(null);
setIsNew(true);
setDraftLabel('');
setDraftKey('');
setDraftType('text');
setDraftStep(activeStep);
setDraftRequired(false);
setDraftPlaceholder('');
setDraftHelpText('');
setDraftOptions('');
setDrawerOpen(true);
};
const saveField = () => {
if (!draftLabel.trim() || !draftKey.trim()) {
notify.error('Label and field key are required.');
return;
}
if (isNew) {
const newField: FormField = {
id: generateId(),
label: draftLabel.trim(),
key: draftKey.trim().replace(/\s+/g, '_').toLowerCase(),
type: draftType,
step: draftStep,
required: draftRequired,
placeholder: draftPlaceholder,
helpText: draftHelpText,
options: draftOptions,
enabled: true,
system: false,
};
setFields((prev) => [...prev, newField]);
notify.success('Field added.');
} else if (editing) {
setFields((prev) =>
prev.map((f) =>
f.id === editing.id
? {
...f,
label: draftLabel.trim(),
key: draftKey.trim().replace(/\s+/g, '_').toLowerCase(),
type: draftType,
step: draftStep,
required: draftRequired,
placeholder: draftPlaceholder,
helpText: draftHelpText,
options: draftOptions,
}
: f
)
);
notify.success('Field updated.');
}
setDrawerOpen(false);
};
const toggleField = (id: string) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, enabled: !f.enabled } : f)));
};
const confirmDelete = () => {
if (!deleteTarget) return;
setFields((prev) => prev.filter((f) => f.id !== deleteTarget.id));
setDeleteTarget(null);
notify.success('Field removed.');
};
const moveField = (id: string, direction: 'up' | 'down') => {
setFields((prev) => {
const stepList = prev.filter((f) => f.step === activeStep);
const otherList = prev.filter((f) => f.step !== activeStep);
const idx = stepList.findIndex((f) => f.id === id);
if (idx < 0) return prev;
const next = [...stepList];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= next.length) return prev;
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
// Rebuild with original order for other steps
return prev.map((f) => {
if (f.step !== activeStep) return f;
return next.shift()!;
});
});
};
const handleSaveSchema = async () => {
setSaving(true);
await new Promise((r) => setTimeout(r, 600));
setSaving(false);
notify.success('Form schema saved successfully.');
};
const stepCounts = STEPS.map((s) => ({
step: s,
total: fields.filter((f) => f.step === s).length,
enabled: fields.filter((f) => f.step === s && f.enabled).length,
}));
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="teal" variant="light">
<IconSettings size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration Form Builder</Title>
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
</div>
</Group>
<Group gap="sm">
<Button leftSection={<IconPlus size={15} />} variant="default" onClick={openNew}>
Add Field
</Button>
<Button leftSection={<IconFilePlus size={15} />} color="teal" loading={saving} onClick={handleSaveSchema}>
Save Schema
</Button>
</Group>
</Group>
{/* Summary stats */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
{[
{ label: 'Total Fields', value: fields.length, color: 'teal' },
{ label: 'Active Fields', value: totalEnabled, color: 'blue' },
{ label: 'Disabled Fields', value: fields.length - totalEnabled, color: 'gray' },
{ label: 'Required Fields', value: fields.filter((f) => f.required && f.enabled).length, color: 'orange' },
].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>
{/* Step tabs */}
<Group gap="sm">
{stepCounts.map(({ step, total, enabled }) => (
<Button
key={step}
variant={activeStep === step ? 'filled' : 'default'}
color={activeStep === step ? 'teal' : undefined}
size="sm"
onClick={() => setActiveStep(step)}
rightSection={
<Badge
size="xs"
color={activeStep === step ? 'white' : 'gray'}
variant="filled"
circle
>
{enabled}/{total}
</Badge>
}
>
{step}
</Button>
))}
</Group>
{/* Field list */}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Text fw={700} fz="sm">{activeStep}</Text>
<Text fz="xs" c="dimmed">
{stepFields.filter((f) => f.enabled).length} of {stepFields.length} fields active
</Text>
</Group>
{stepFields.length === 0 ? (
<Text fz="sm" c="dimmed" ta="center" py="xl">
No fields in this step. Click "Add Field" to add one.
</Text>
) : (
<Stack gap="xs">
{stepFields.map((field, i) => (
<FieldCard
key={field.id}
field={field}
index={i}
total={stepFields.length}
onEdit={() => openEdit(field)}
onDelete={() => setDeleteTarget(field)}
onToggle={() => toggleField(field.id)}
onMoveUp={() => moveField(field.id, 'up')}
onMoveDown={() => moveField(field.id, 'down')}
/>
))}
</Stack>
)}
</Paper>
{/* Edit / Add Drawer */}
<Drawer
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
position="right"
size="md"
title={<Text fw={700}>{isNew ? 'Add New Field' : `Edit — ${editing?.label}`}</Text>}
>
<Stack gap="md">
<TextInput
label="Field Label"
placeholder="e.g. Vessel Name"
required
value={draftLabel}
onChange={(e) => {
const val = e.currentTarget.value;
setDraftLabel(val);
if (isNew)
setDraftKey(val.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''));
}}
/>
<TextInput
label="Field Key (internal)"
placeholder="e.g. vessel_name"
required
value={draftKey}
onChange={(e) =>
setDraftKey(
e.currentTarget.value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
)
}
description="Used as the API field name. Letters, numbers and underscores only."
/>
<Select
label="Field Type"
data={FIELD_TYPES}
value={draftType}
onChange={(v) => setDraftType((v as FieldType) ?? 'text')}
/>
<Select
label="Step / Section"
data={STEPS.map((s) => ({ value: s, label: s }))}
value={draftStep}
onChange={(v) => setDraftStep((v as FieldStep) ?? 'Vessel Details')}
/>
<TextInput
label="Placeholder Text"
placeholder="e.g. Enter vessel name"
value={draftPlaceholder}
onChange={(e) => setDraftPlaceholder(e.currentTarget.value)}
/>
<TextInput
label="Help Text"
placeholder="Short hint shown below the field"
value={draftHelpText}
onChange={(e) => setDraftHelpText(e.currentTarget.value)}
/>
{draftType === 'select' && (
<Textarea
label="Options (comma-separated)"
placeholder="Option A,Option B,Option C"
value={draftOptions}
onChange={(e) => setDraftOptions(e.currentTarget.value)}
rows={3}
description="Each comma-separated value becomes a dropdown option."
/>
)}
<Checkbox
label="Required field"
checked={draftRequired}
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
/>
<Divider />
<Group justify="flex-end">
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
<Button color="teal" onClick={saveField}>
{isNew ? 'Add Field' : 'Save Changes'}
</Button>
</Group>
</Stack>
</Drawer>
{/* Delete confirm modal */}
<Modal
opened={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Field"
size="sm"
>
<Stack gap="md">
<Text fz="sm">
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
This cannot be undone.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,18 +1,23 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
Paper,
Progress,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
@@ -20,150 +25,511 @@ import {
} from '@mantine/core';
import {
IconAnchor,
IconCheck,
IconCircleCheck,
IconClock,
IconEye,
IconSearch,
IconShip,
IconShieldCheck,
IconX,
IconAlertCircle,
IconClockHour4,
} from '@tabler/icons-react';
import { MOCK_REGISTRATIONS, RENEWAL_COLOR, STATUS_COLOR } from '../mock';
import { notify } 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
// ---------------------------------------------------------------------------
export function VesselRegistrationQueuePage() {
const navigate = useNavigate();
const [registrations] = useState(MOCK_REGISTRATIONS);
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 [renewalFilter, setRenewalFilter] = useState<string | null>(null);
const [selectedReg, setSelectedReg] = useState<VesselRegistration | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<VesselRegistration[]>();
const fetched = useRef(false);
const stats = {
total: registrations.length,
pending: registrations.filter((r) => r.status === 'Pending').length,
underReview: registrations.filter((r) => r.status === 'Under Review').length,
approved: registrations.filter((r) => r.status === 'Approved').length,
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 = registrations.filter((r) => {
const filtered = apps.filter((r) => {
const q = search.toLowerCase();
const matchSearch = !q
|| r.vesselName.toLowerCase().includes(q)
|| r.id.toLowerCase().includes(q)
|| r.owner.name.toLowerCase().includes(q);
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 matchCategory = !categoryFilter || r.category === categoryFilter;
const matchRenewal = !renewalFilter || r.renewal === renewalFilter;
return matchSearch && matchStatus && matchCategory && matchRenewal;
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</Title>
<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="sm">
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total', value: stats.total, color: 'blue', icon: IconShip },
{ label: 'Pending', value: stats.pending, color: 'gray', icon: IconClock },
{ label: 'Under Review', value: stats.underReview, color: 'blue', icon: IconEye },
{ 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>
{ 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">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Registration Queue</Text>
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search by vessel, registration ID or owner…"
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', 'Under Review', 'Correction Required', 'Resubmitted', 'Approved', 'Rejected']}
value={statusFilter}
onChange={setStatusFilter}
clearable
size="sm"
style={{ width: rem(170) }}
/>
<Select
placeholder="All Categories"
data={['Inland Waterway', 'Sea-going']}
value={categoryFilter}
onChange={setCategoryFilter}
clearable
size="sm"
style={{ width: rem(160) }}
/>
<Select
placeholder="All Renewal"
data={['OK', 'Due Soon', 'Overdue']}
value={renewalFilter}
onChange={setRenewalFilter}
clearable
size="sm"
style={{ width: rem(140) }}
/>
</Group>
</Group>
{filtered.length === 0 ? (
<Box py="xl" ta="center">
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconAnchor size={22} /></ThemeIcon>
<Text fz="sm" c="dimmed">No registrations found</Text>
</Box>
) : (
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<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>
{['Registration ID', 'Vessel Name', 'Vessel Type', 'Category', 'Owner', 'Submitted', 'Status', 'Renewal', ''].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
))}
<Table.Td colSpan={9}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No registrations found</Text>
</Table.Td>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((reg) => (
<Table.Tr key={reg.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{reg.id}</Text></Table.Td>
<Table.Td><Text fz="xs" fw={500}>{reg.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.vesselType}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.owner.name}</Text></Table.Td>
<Table.Td><Text fz="xs">{reg.submitted}</Text></Table.Td>
<Table.Td><Badge color={STATUS_COLOR[reg.status]} variant="light" size="xs">{reg.status}</Badge></Table.Td>
<Table.Td>{reg.renewal ? <Badge color={RENEWAL_COLOR[reg.renewal]} variant="light" size="xs">{reg.renewal}</Badge> : <Text fz="xs" c="dimmed"></Text>}</Table.Td>
<Table.Td>
<Button size="xs" variant="light" leftSection={<IconEye size={13} />} onClick={() => navigate(`/vessel-registrations/${reg.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 {registrations.length} registrations</Text>
</Group>
)}
)}
</Table.Tbody>
</Table>
</Paper>
<VesselDrawer
reg={selectedReg}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/vessel-registration-queue/${id}`)}
/>
</Stack>
);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import {
Badge,
Card,
@@ -10,167 +11,225 @@ import {
Text,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconAlertCircle,
IconCircleCheck,
IconShip,
IconWaveSine,
} from '@tabler/icons-react';
import { MOCK_REGISTRATIONS, RENEWAL_COLOR, STATUS_COLOR, type RegistrationStatus } from '../mock';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration } from './VesselRegistrationQueuePage';
interface KpiSpec {
label: string;
value: number;
color: string;
icon: typeof IconShip;
}
function KpiCard({ label, value, color, icon: Icon }: KpiSpec) {
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}>{label}</Text>
</Card>
);
}
function DistributionBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
const pct = total ? Math.round((count / total) * 100) : 0;
return (
<div>
<Group justify="space-between" mb={4}>
<Badge color={color} variant="light" size="sm">{label}</Badge>
<Group gap="xs">
<Text fz="xs" fw={600}>{count}</Text>
<Text fz="xs" c="dimmed">{pct}%</Text>
</Group>
</Group>
<Progress value={pct} color={color} radius="xl" size="sm" />
</div>
);
}
const TYPE_COLORS = ['blue', 'teal', 'grape', 'orange', 'cyan', 'indigo', 'lime'];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function VesselRegistrationReportPage() {
const registrations = MOCK_REGISTRATIONS;
const total = registrations.length;
const inland = registrations.filter((r) => r.category === 'Inland Waterway').length;
const seaGoing = registrations.filter((r) => r.category === 'Sea-going').length;
const approvedThisYear = registrations.filter((r) => r.status === 'Approved' && r.submitted.startsWith('2025')).length;
const data = MOCK_VESSEL_REGISTRATIONS;
const statuses: RegistrationStatus[] = ['Pending', 'Under Review', 'Correction Required', 'Resubmitted', 'Approved', 'Rejected'];
const statusCounts = statuses
.map((s) => ({ status: s, count: registrations.filter((r) => r.status === s).length }))
.filter((s) => s.count > 0);
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 typeCounts = Object.entries(
registrations.reduce<Record<string, number>>((acc, r) => {
acc[r.vesselType] = (acc[r.vesselType] ?? 0) + 1;
return acc;
}, {})
).sort((a, b) => b[1] - a[1]);
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 recent = [...registrations]
.sort((a, b) => (a.submitted < b.submitted ? 1 : -1))
.slice(0, 10);
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 renewals = registrations.filter((r) => r.renewal === 'Due Soon' || r.renewal === 'Overdue');
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">Monitor registration activity and renewal status</Text>
<Text fz="sm" c="dimmed">Summary of all vessel registrations and renewal status</Text>
</div>
{/* KPI cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<KpiCard label="Total Registered" value={total} color="blue" icon={IconShip} />
<KpiCard label="Inland Vessels" value={inland} color="cyan" icon={IconAnchor} />
<KpiCard label="Sea-going Vessels" value={seaGoing} color="indigo" icon={IconShip} />
<KpiCard label="Approved This Year" value={approvedThisYear} color="teal" icon={IconCircleCheck} />
{/* 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">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Status Distribution</Text>
{/* Status Distribution */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Status Distribution</Text>
<Stack gap="sm">
{statusCounts.map(({ status, count }) => (
<DistributionBar key={status} label={status} count={count} total={total} color={STATUS_COLOR[status]} />
{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>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Vessel Type Breakdown</Text>
{/* Vessel Type Breakdown */}
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="md">Vessel Type Breakdown</Text>
<Stack gap="sm">
{typeCounts.map(([type, count], i) => (
<DistributionBar key={type} label={type} count={count} total={total} color={TYPE_COLORS[i % TYPE_COLORS.length]} />
{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>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Recent Registrations</Text>
<Table highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
{['Registration ID', 'Vessel', 'Category', 'Owner', 'Submitted', '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>
{recent.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fz="xs" fw={600} c="blue.7">{r.id}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.owner.name}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.submitted}</Text></Table.Td>
<Table.Td><Badge color={STATUS_COLOR[r.status]} variant="light" size="xs">{r.status}</Badge></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Group gap="xs" mb="md">
<IconAlertTriangle size={16} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Renewal Tracking</Text>
{/* 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>
{renewals.length === 0 ? (
<Text fz="sm" c="dimmed">No vessels due for renewal.</Text>
{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 highlightOnHover fz="sm" verticalSpacing="sm">
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
{['Vessel', 'Category', 'Expiry Date', 'Renewal Status'].map((h) => (
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
))}
<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>
{renewals.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Text fz="xs" fw={500}>{r.vesselName}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.category}</Text></Table.Td>
<Table.Td><Text fz="xs">{r.expiryDate ?? '—'}</Text></Table.Td>
<Table.Td><Badge color={RENEWAL_COLOR[r.renewal!]} variant="light" size="xs">{r.renewal}</Badge></Table.Td>
{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>
);
}

View File

@@ -1,21 +1,18 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
MultiSelect,
Paper,
Select,
SimpleGrid,
Stack,
Stepper,
Tabs,
Text,
Textarea,
ThemeIcon,
@@ -23,75 +20,41 @@ import {
rem,
} from '@mantine/core';
import {
IconAlertTriangle,
IconAnchor,
IconArrowLeft,
IconCamera,
IconCertificate,
IconCheck,
IconCircleCheck,
IconClock,
IconDownload,
IconExternalLink,
IconEdit,
IconEye,
IconFileDescription,
IconFiles,
IconInfoCircle,
IconUserQuestion,
IconId,
IconShieldCheck,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
applyDecision,
generateCertificates,
MOCK_REGISTRATIONS,
STATUS_COLOR,
type RegistrationDocument,
type RegistrationStatus,
} from '../mock';
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
import type { VesselRegistration, VesselRegStatus } from './VesselRegistrationQueuePage';
// ponytail: view/download hit this demo data-URI, wire to real file storage when backend lands.
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
// ---------------------------------------------------------------------------
// Certificate definitions
// ---------------------------------------------------------------------------
const INLAND_CERTIFICATES = [
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
];
function getDocUrl(fileName: string) {
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
}
function DocViewer({ label, fileName, fileType }: RegistrationDocument) {
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>
<Group grow mt="xs">
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
View
</Button>
<Button size="xs" variant="light" component="a" href={url} download={fileName} leftSection={<IconDownload size={13} />}>
Download
</Button>
</Group>
</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>
</>
);
}
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>
@@ -101,261 +64,330 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
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
// ---------------------------------------------------------------------------
export function VesselRegistrationReviewPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const record = MOCK_REGISTRATIONS.find((r) => r.id === id);
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false);
const [status, setStatus] = useState<RegistrationStatus | undefined>(record?.status);
const [remarks, setRemarks] = useState(record?.remarks ?? '');
const [correctionFields, setCorrectionFields] = useState<string[]>(record?.correctionFields ?? []);
const [, forceUpdate] = useState(0);
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('');
if (!record || !status) {
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">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-registrations')}><IconArrowLeft size={18} /></ActionIcon>
<Title order={3}>Vessel Registration</Title>
</Group>
<Alert color="red" icon={<IconInfoCircle size={16} />}>Registration not found.</Alert>
<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 isTerminal = status === 'Approved' || status === 'Rejected';
const hasRemarks = Boolean(record.remarks || (record.correctionFields && record.correctionFields.length > 0));
const hasCertificates = Boolean(record.certificates && record.certificates.length > 0);
const decide = (newStatus: RegistrationStatus, label: string) => {
applyDecision(record, newStatus, remarks, newStatus === 'Correction Required' ? correctionFields : undefined);
if (newStatus === 'Approved') generateCertificates(record);
setStatus(newStatus);
forceUpdate((n) => n + 1);
notify.success(`Registration ${label}. SMS and email notification sent.`);
};
const certs = reg.category === 'Sea-going Vessel (International)' ? SEAGOING_CERTIFICATES : INLAND_CERTIFICATES;
return (
<Stack gap="md">
{/* Header */}
<Group justify="space-between">
<Group gap="sm">
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/vessel-registrations')}>
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>Registration Review {record.vesselName}</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{record.id}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">Submitted {record.submitted}</Text>
</Group>
</div>
<Group gap="xs">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration-queue')}>
Back to Queue
</Button>
</Group>
<Badge size="lg" variant="light" color={STATUS_COLOR[status]}>{status}</Badge>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
{/* Decision bar / terminal notice */}
{!isTerminal ? (
<Paper withBorder radius="lg" p="md" bg="gray.0">
<Stack gap="sm">
<Textarea
label="Officer Remarks"
placeholder="Add notes, or the reason for correction / rejection…"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<MultiSelect
label="Fields / Documents Requiring Correction (optional)"
placeholder="Select fields or documents…"
data={[...record.documents.map((d) => d.label), 'Vessel Details', 'Technical Specifications', 'Ownership Information']}
value={correctionFields}
onChange={setCorrectionFields}
clearable
/>
<Group gap="sm" justify="flex-end">
<Button
size="sm"
color="blue"
variant="light"
leftSection={<IconUserQuestion size={15} />}
onClick={() => decide('Under Review', 'marked under review')}
disabled={status === 'Under Review'}
>
Mark Under Review
</Button>
<Button
size="sm"
color="orange"
variant="light"
leftSection={<IconAlertTriangle size={15} />}
onClick={() => decide('Correction Required', 'sent back for correction')}
disabled={!remarks.trim()}
>
Request Correction
</Button>
<Button
size="sm"
color="red"
variant="light"
leftSection={<IconX size={15} />}
onClick={() => decide('Rejected', 'rejected')}
disabled={!remarks.trim()}
>
Reject
</Button>
<Button
size="sm"
color="teal"
leftSection={<IconCircleCheck size={15} />}
onClick={() => decide('Approved', 'approved')}
>
Approve
</Button>
</Group>
{!remarks.trim() && (
<Text fz="xs" c="dimmed" ta="right">Remarks are required to request correction or reject this application.</Text>
)}
</Stack>
</Paper>
) : (
<Alert
variant="light"
color={STATUS_COLOR[status]}
icon={status === 'Approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
>
{status === 'Approved'
? <>Registration approved. Certificates generated for {record.owner.name}.</>
: <>This registration was rejected. {record.remarks}</>}
<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>
)}
{/* Timeline */}
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Timeline</Text>
<Stepper active={record.timeline.filter((t) => t.done).length - 1} size="sm" color="teal" orientation="horizontal">
{record.timeline.map((step, i) => (
<Stepper.Step
key={i}
label={step.event}
description={step.date ?? 'Pending'}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/>
))}
</Stepper>
</Paper>
{/* 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>
)}
{/* Tabs */}
<Tabs defaultValue="information" variant="outline">
<Tabs.List mb="md">
<Tabs.Tab value="information" leftSection={<IconInfoCircle size={15} />}>Information</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<IconFiles size={15} />}>Documents</Tabs.Tab>
{hasRemarks && <Tabs.Tab value="remarks" leftSection={<IconAlertTriangle size={15} />}>Officer Remarks</Tabs.Tab>}
{hasCertificates && <Tabs.Tab value="certificates" leftSection={<IconCircleCheck size={15} />}>Certificates</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="information">
<Stack gap="md">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Vessel Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Vessel Name" value={record.vesselName} />
<InfoRow label="Vessel Type" value={record.vesselType} />
<InfoRow label="Category" value={record.category} />
<InfoRow label="Registration Area" value={record.registrationArea} />
<InfoRow label="Flag State" value={record.flagState} />
<InfoRow
label={record.category === 'Sea-going' ? 'Gross Tonnage (GT)' : 'Passenger Capacity'}
value={(record.category === 'Sea-going' ? record.grossTonnage : record.passengerCapacity) ?? ''}
/>
<InfoRow label="Length / Breadth / Depth" value={`${record.length} / ${record.breadth} / ${record.depth}`} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Technical Specifications</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow
label={record.category === 'Sea-going' ? 'IMO Number' : 'Hull Number'}
value={(record.category === 'Sea-going' ? record.imoNumber : record.hullNumber) ?? ''}
/>
<InfoRow label="Shipyard" value={record.shipyard} />
<InfoRow label="Year Built" value={record.yearBuilt} />
<InfoRow label="Engine Type" value={record.engineType} />
<InfoRow label="Engine Number" value={record.engineNumber} />
<InfoRow label="Engine Power" value={record.enginePower} />
<InfoRow label="Hull Material" value={record.hullMaterial} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Ownership Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoRow label="Owner Name" value={record.owner.name} />
<InfoRow label="National ID / TIN" value={record.owner.idOrTin} />
<InfoRow label="Phone Number" value={record.owner.phone} />
<InfoRow label="Email Address" value={record.owner.email} />
<InfoRow label="Address" value={record.owner.address} />
</SimpleGrid>
</Paper>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="documents">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Documents</Text>
<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">
{record.documents.map((doc) => <DocViewer key={doc.key} label={doc.label} fileName={doc.fileName} fileType={doc.fileType} />)}
<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>
</Tabs.Panel>
{hasRemarks && (
<Tabs.Panel value="remarks">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="sm">Officer Remarks</Text>
{record.remarks && <Text fz="sm" mb="sm">{record.remarks}</Text>}
{record.correctionFields && record.correctionFields.length > 0 && (
<Group gap={6}>
{record.correctionFields.map((f) => <Badge key={f} size="sm" color="orange" variant="light">{f}</Badge>)}
</Group>
)}
</Paper>
</Tabs.Panel>
)}
{/* 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>
{hasCertificates && (
<Tabs.Panel value="certificates">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text>
<Stack gap="sm">
{record.certificates!.map((cert) => (
<div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="xs">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">No. {cert.number} Issued {cert.issueDate}</Text>
</div>
<Button
size="xs"
variant="light"
leftSection={<IconDownload size={13} />}
component="a"
href={DEMO_PDF}
download={`${cert.name.replace(/\s+/g, '-')}-${cert.number}.pdf`}
>
Download
</Button>
{/* 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>
<Divider mt="sm" />
</div>
))}
</Stack>
</Paper>
</Tabs.Panel>
)}
</Tabs>
</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>
);
}

View File

@@ -0,0 +1,452 @@
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';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
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}`)}
/>
</Stack>
);
}
export const WAIVER_ICON = IconShieldOff;

View File

@@ -0,0 +1,361 @@
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;
}
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>
);
}