mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: add Shipping Agent License Renewal and Waiver Application pages
- Implement ShippingAgentLicenseRenewalPage for submitting renewal documents with file uploads. - Create WaiverApplicationPage for multi-step waiver application process, including document uploads and review. - Add WaiverPage to display application status and details, including waiver letter download functionality. - Introduce document handling components and validation for required fields in both applications.
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export const am: Translations = {
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
||||
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
||||
logisticsHeadDashboard: 'የሎጂስቲክስ ኃላፊ ዳሽቦርድ',
|
||||
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
|
||||
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
|
||||
combinedLicense: 'የተቀናጀ ፈቃድ',
|
||||
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||
mtoLicense: 'የMTO ፈቃድ',
|
||||
waiver: 'ነፃ ፈቃድ',
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
|
||||
@@ -22,6 +22,13 @@ export const en = {
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
vesselRegistrationReport: 'Vessel Registration Report',
|
||||
ownershipTransferQueue: 'Ownership Transfer Queue',
|
||||
logisticsHeadDashboard: 'Logistics Head Dashboard',
|
||||
freightForwarderLicense: 'Freight Forwarder License',
|
||||
shippingAgentLicense: 'Shipping Agent License',
|
||||
combinedLicense: 'Combined License',
|
||||
jointInvestmentLicense: 'Joint Investment License',
|
||||
mtoLicense: 'MTO License',
|
||||
waiver: 'Waiver',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
IconAnchor,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
@@ -39,6 +43,13 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription },
|
||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
||||
{ to: '/freight-forwarder-license', label: 'nav.freightForwarderLicense', icon: IconTruck },
|
||||
{ to: '/shipping-agent-license', label: 'nav.shippingAgentLicense', icon: IconShip },
|
||||
{ to: '/combined-license', label: 'nav.combinedLicense', icon: IconStack2 },
|
||||
{ to: '/joint-investment-license', label: 'nav.jointInvestmentLicense', icon: IconUsers },
|
||||
{ to: '/mto-license', label: 'nav.mtoLicense', icon: IconTruck },
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
|
||||
@@ -37,6 +37,19 @@ import { VesselRegistrationFormBuilderPage } from '../features/vessel-registrati
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { FreightForwarderLicenseQueuePage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
|
||||
import { FreightForwarderLicenseReviewPage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseReviewPage';
|
||||
import { ShippingAgentLicenseQueuePage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
|
||||
import { ShippingAgentLicenseReviewPage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseReviewPage';
|
||||
import { CombinedLicenseQueuePage } from '../features/combined-license/pages/CombinedLicenseQueuePage';
|
||||
import { CombinedLicenseReviewPage } from '../features/combined-license/pages/CombinedLicenseReviewPage';
|
||||
import { JointInvestmentLicenseQueuePage } from '../features/joint-investment-license/pages/JointInvestmentLicenseQueuePage';
|
||||
import { JointInvestmentLicenseReviewPage } from '../features/joint-investment-license/pages/JointInvestmentLicenseReviewPage';
|
||||
import { MtoLicenseQueuePage } from '../features/mto-license/pages/MtoLicenseQueuePage';
|
||||
import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseReviewPage';
|
||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -59,6 +72,7 @@ const router = createBrowserRouter([
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
|
||||
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
@@ -82,6 +96,18 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
{ path: 'freight-forwarder-license', element: <FreightForwarderLicenseQueuePage /> },
|
||||
{ path: 'freight-forwarder-license/:id', element: <FreightForwarderLicenseReviewPage /> },
|
||||
{ path: 'shipping-agent-license', element: <ShippingAgentLicenseQueuePage /> },
|
||||
{ path: 'shipping-agent-license/:id', element: <ShippingAgentLicenseReviewPage /> },
|
||||
{ path: 'combined-license', element: <CombinedLicenseQueuePage /> },
|
||||
{ path: 'combined-license/:id', element: <CombinedLicenseReviewPage /> },
|
||||
{ path: 'joint-investment-license', element: <JointInvestmentLicenseQueuePage /> },
|
||||
{ path: 'joint-investment-license/:id', element: <JointInvestmentLicenseReviewPage /> },
|
||||
{ path: 'mto-license', element: <MtoLicenseQueuePage /> },
|
||||
{ path: 'mto-license/:id', element: <MtoLicenseReviewPage /> },
|
||||
{ path: 'waiver', element: <WaiverQueuePage /> },
|
||||
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user