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: 'የመርከብ ቅጽ መገንቢያ',
|
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||||
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
|
||||||
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
|
||||||
|
logisticsHeadDashboard: 'የሎጂስቲክስ ኃላፊ ዳሽቦርድ',
|
||||||
|
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
|
||||||
|
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
|
||||||
|
combinedLicense: 'የተቀናጀ ፈቃድ',
|
||||||
|
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||||
|
mtoLicense: 'የMTO ፈቃድ',
|
||||||
|
waiver: 'ነፃ ፈቃድ',
|
||||||
menu: 'ምናሌ',
|
menu: 'ምናሌ',
|
||||||
dashboard: 'ዳሽቦርድ',
|
dashboard: 'ዳሽቦርድ',
|
||||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export const en = {
|
|||||||
vesselFormBuilder: 'Vessel Form Builder',
|
vesselFormBuilder: 'Vessel Form Builder',
|
||||||
vesselRegistrationReport: 'Vessel Registration Report',
|
vesselRegistrationReport: 'Vessel Registration Report',
|
||||||
ownershipTransferQueue: 'Ownership Transfer Queue',
|
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',
|
cocQueue: 'CoC / CoP Queue',
|
||||||
endorsementQueue: 'Endorsement Queue',
|
endorsementQueue: 'Endorsement Queue',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
IconAnchor,
|
IconAnchor,
|
||||||
IconFilePlus,
|
IconFilePlus,
|
||||||
IconGauge,
|
IconGauge,
|
||||||
|
IconShieldOff,
|
||||||
|
IconShip,
|
||||||
|
IconStack2,
|
||||||
|
IconTruck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
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-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
|
||||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
|
||||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription },
|
{ 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: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
{ 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 { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
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([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -59,6 +72,7 @@ const router = createBrowserRouter([
|
|||||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', element: <DashboardPage /> },
|
||||||
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
|
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
|
||||||
|
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||||
{ path: 'locations', element: <LocationPage /> },
|
{ path: 'locations', element: <LocationPage /> },
|
||||||
@@ -82,6 +96,18 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
{ 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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Company Information' },
|
||||||
|
{ label: 'Shipping Agreement & Bank Letter' },
|
||||||
|
{ label: 'Vehicle, Office & Terminal' },
|
||||||
|
{ label: 'Employees' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Signed agreement with a shipping company', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
|
||||||
|
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'canvasserDocs', label: 'Canvasser Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'adminDocs', label: 'Administrative Staff Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'ceoDocs', label: 'CEO / General Manager Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||||
|
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CombinedLicenseApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tradeName, setTradeName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [businessAddress, setBusinessAddress] = useState('');
|
||||||
|
const [officeAddress, setOfficeAddress] = useState('');
|
||||||
|
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||||
|
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [shippingCompanyName, setShippingCompanyName] = useState('');
|
||||||
|
const [agreementRefNumber, setAgreementRefNumber] = useState('');
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [accountHolderName, setAccountHolderName] = useState('');
|
||||||
|
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||||
|
|
||||||
|
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||||
|
const [plateNumber, setPlateNumber] = useState('');
|
||||||
|
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||||
|
const [terminalName, setTerminalName] = useState('');
|
||||||
|
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [bookingClerkName, setBookingClerkName] = useState('');
|
||||||
|
const [canvasserName, setCanvasserName] = useState('');
|
||||||
|
const [ceoName, setCeoName] = useState('');
|
||||||
|
const [adminStaffName, setAdminStaffName] = useState('');
|
||||||
|
const [transit1Name, setTransit1Name] = useState('');
|
||||||
|
const [transit2Name, setTransit2Name] = useState('');
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||||
|
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||||
|
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||||
|
);
|
||||||
|
if (active === 1) return (
|
||||||
|
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
|
||||||
|
!!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000
|
||||||
|
);
|
||||||
|
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
|
||||||
|
if (active === 3) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
|
||||||
|
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-licenses/combined',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||||
|
businessAddress, officeAddress, applicantType, ownershipType,
|
||||||
|
shippingCompanyName, agreementRefNumber, bankName, accountHolderName, capitalAmount,
|
||||||
|
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
|
||||||
|
bookingClerkName, canvasserName, ceoName, adminStaffName, transit1Name, transit2Name,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('Combined License application submitted successfully!');
|
||||||
|
navigate('/combined-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Combined License Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Company Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Shipping Company Agreement" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} mt="md">
|
||||||
|
Bank letter must show a minimum combined capital of 1,500,000 ETB (no separate amounts required per license type).
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||||
|
<NumberInput
|
||||||
|
label="Capital Amount (ETB)"
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
value={capitalAmount}
|
||||||
|
onChange={setCapitalAmount}
|
||||||
|
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Vehicle Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||||
|
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Office Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Terminal Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
|
||||||
|
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented / Agreement']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
This combined license requires both Shipping Agent staff roles and two ERB-qualified transit/customs employees.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Shipping Agent Employee Profiles" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Freight Forwarder Transit Employees" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 5 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="Trade Name" value={tradeName} />
|
||||||
|
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||||
|
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||||
|
<ReviewRow label="Business Address" value={businessAddress} />
|
||||||
|
<ReviewRow label="Office Address" value={officeAddress} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Shipping Agreement & Bank Letter</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
|
||||||
|
<ReviewRow label="Bank Name" value={bankName} />
|
||||||
|
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Terminal" value={terminalName} />
|
||||||
|
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
|
||||||
|
<ReviewRow label="Canvasser" value={canvasserName} />
|
||||||
|
<ReviewRow label="CEO" value={ceoName} />
|
||||||
|
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||||
|
<ReviewRow label="Transit Employee 1" value={transit1Name} />
|
||||||
|
<ReviewRow label="Transit Employee 2" value={transit2Name} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/combined-license') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCertificate,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconDownload,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||||
|
|
||||||
|
interface CombinedLicenseApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombinedLicensePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<CombinedLicenseApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-licenses/combined/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Combined Shipping Agent + Freight Forwarder License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Apply for a single license covering both shipping agency and freight forwarding services</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for a Combined License</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Fulfill both Shipping Agent and Freight Forwarder requirements in a single application</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Shipping company agreement" />
|
||||||
|
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
|
||||||
|
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||||
|
<RequirementItem label="Office title deed or office rental agreement" />
|
||||||
|
<RequirementItem label="Terminal agreement or terminal title deed" />
|
||||||
|
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff, and CEO profiles" />
|
||||||
|
<RequirementItem label="Two ERB-qualified transit/customs employees" />
|
||||||
|
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/combined-license/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About the Combined License</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
This single license lets you legally provide both freight forwarding and shipping agency services.
|
||||||
|
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||||
|
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Payment Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||||
|
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||||
|
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Certificate Issued' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application.status === 'Certificate Issued' && (
|
||||||
|
<div>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||||
|
</Group>
|
||||||
|
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||||
|
Your Combined License certificate is ready. Valid until {application.expiryDate}.
|
||||||
|
</Alert>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">Combined License Certificate</Text>
|
||||||
|
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||||
|
Download Certificate
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RENEWAL_DOCS: DocSlot[] = [
|
||||||
|
{ key: 'prevCertificate', label: 'Previous Combined License Certificate', description: 'Your current/expiring certificate', required: true },
|
||||||
|
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months (Freight Forwarder requirement)', required: true },
|
||||||
|
{ key: 'clearanceEvidence', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months (Shipping Agent requirement)', required: true },
|
||||||
|
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||||
|
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombinedLicenseRenewalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({ url: '/logistics-licenses/combined/renew', method: 'POST', body: {} }).unwrap();
|
||||||
|
notify.success('Renewal request submitted successfully!');
|
||||||
|
navigate('/combined-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Renewal submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Renew Combined License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Renewal applies the controls of both the Shipping Agent and Freight Forwarder licenses. If your vehicle or
|
||||||
|
office rental agreement has expired since your last submission, an updated agreement is required.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{RENEWAL_DOCS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit Renewal Request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconTruck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Company Information' },
|
||||||
|
{ label: 'Bank Letter' },
|
||||||
|
{ label: 'Vehicle & Office' },
|
||||||
|
{ label: 'Employees' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'ceoCv', label: 'CEO CV & Work Agreement', description: 'CEO / General Manager CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'adminCv', label: 'Administrative Staff CV & Work Agreement', description: 'Administrative staff CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||||
|
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FreightForwarderLicenseApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tradeName, setTradeName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [businessAddress, setBusinessAddress] = useState('');
|
||||||
|
const [officeAddress, setOfficeAddress] = useState('');
|
||||||
|
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||||
|
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 1 — Bank Letter
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [accountHolderName, setAccountHolderName] = useState('');
|
||||||
|
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||||
|
|
||||||
|
// Step 2 — Vehicle & Office
|
||||||
|
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||||
|
const [plateNumber, setPlateNumber] = useState('');
|
||||||
|
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 3 — Employees
|
||||||
|
const [ceoName, setCeoName] = useState('');
|
||||||
|
const [adminStaffName, setAdminStaffName] = useState('');
|
||||||
|
const [transit1Name, setTransit1Name] = useState('');
|
||||||
|
const [transit2Name, setTransit2Name] = useState('');
|
||||||
|
|
||||||
|
// Step 4 — Documents
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||||
|
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||||
|
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||||
|
);
|
||||||
|
if (active === 1) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000;
|
||||||
|
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership;
|
||||||
|
if (active === 3) return !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
|
||||||
|
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-licenses/freight-forwarder',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||||
|
businessAddress, officeAddress, applicantType, ownershipType,
|
||||||
|
bankName, accountHolderName, capitalAmount,
|
||||||
|
vehicleOwnership, plateNumber, officeOwnership,
|
||||||
|
ceoName, adminStaffName, transit1Name, transit2Name,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('Freight Forwarder License application submitted successfully!');
|
||||||
|
navigate('/freight-forwarder-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Freight Forwarder License Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Company Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Bank letter must show a minimum capital of 1,500,000 ETB.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||||
|
<NumberInput
|
||||||
|
label="Capital Amount (ETB)"
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
value={capitalAmount}
|
||||||
|
onChange={setCapitalAmount}
|
||||||
|
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Vehicle Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||||
|
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Office Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
At least two qualified transit/customs employees certified by the Ethiopian Revenue Bureau are required.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Employee Profiles" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 5 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="Trade Name" value={tradeName} />
|
||||||
|
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||||
|
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||||
|
<ReviewRow label="Business Address" value={businessAddress} />
|
||||||
|
<ReviewRow label="Office Address" value={officeAddress} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Bank Name" value={bankName} />
|
||||||
|
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Vehicle, Office & Employees</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||||
|
<ReviewRow label="CEO" value={ceoName} />
|
||||||
|
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||||
|
<ReviewRow label="Transit Employee 1" value={transit1Name} />
|
||||||
|
<ReviewRow label="Transit Employee 2" value={transit2Name} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/freight-forwarder-license') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconTruck size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCertificate,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconDownload,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconTruck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||||
|
|
||||||
|
interface FreightForwarderApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreightForwarderLicensePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<FreightForwarderApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<FreightForwarderApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-licenses/freight-forwarder/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Freight Forwarder License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Apply for and manage your Freight Forwarder License</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconTruck size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for a Freight Forwarder License</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, and employee information</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
|
||||||
|
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||||
|
<RequirementItem label="Office title deed or office rental agreement" />
|
||||||
|
<RequirementItem label="CEO and administrative staff CVs and work agreements" />
|
||||||
|
<RequirementItem label="Two qualified transit/customs employees (ERB certified)" />
|
||||||
|
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconTruck size={18} />} onClick={() => navigate('/freight-forwarder-license/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About Freight Forwarder Licensing</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||||
|
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Payment Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||||
|
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||||
|
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconTruck size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Certificate Issued' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application.status === 'Certificate Issued' && (
|
||||||
|
<div>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||||
|
</Group>
|
||||||
|
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||||
|
Your Freight Forwarder License certificate is ready. Valid until {application.expiryDate}.
|
||||||
|
</Alert>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">Freight Forwarder License Certificate</Text>
|
||||||
|
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||||
|
Download Certificate
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconTruck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { FileButton } from '@mantine/core';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RENEWAL_DOCS: DocSlot[] = [
|
||||||
|
{ key: 'prevCertificate', label: 'Previous Freight Forwarder Certificate', description: 'Your current/expiring certificate', required: true },
|
||||||
|
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months', required: true },
|
||||||
|
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||||
|
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreightForwarderLicenseRenewalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({ url: '/logistics-licenses/freight-forwarder/renew', method: 'POST', body: {} }).unwrap();
|
||||||
|
notify.success('Renewal request submitted successfully!');
|
||||||
|
navigate('/freight-forwarder-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Renewal submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Renew Freight Forwarder License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{RENEWAL_DOCS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit Renewal Request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconBuildingBank,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Company Information' },
|
||||||
|
{ label: 'JV / Ownership Information' },
|
||||||
|
{ label: 'Organizational Experience' },
|
||||||
|
{ label: 'Transit Professionals' },
|
||||||
|
{ label: 'Logistics Capacity' },
|
||||||
|
{ label: 'Capital Information' },
|
||||||
|
{ label: 'Business License & Registration' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'applicationLetter', label: 'Application Letter / Online Form', description: 'Signed application letter or completed online form', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'establishmentDoc', label: 'Company Establishment Document', description: 'Company establishment / formation document', required: true, icon: IconId },
|
||||||
|
{ key: 'memorandum', label: 'Memorandum / Articles of Association / Bylaw', description: 'Memorandum, articles of association, or bylaw document', required: true, icon: IconId },
|
||||||
|
{ key: 'orgProfile', label: 'Organizational Profile', description: 'Organizational profile document', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'renewedBusinessLicense', label: 'Renewed Business License', description: 'Current renewed business license', required: true, icon: IconId },
|
||||||
|
{ key: 'commercialRegCert', label: 'Commercial Registration Certificate', description: 'Commercial registration certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'sectorEvidence', label: 'Evidence Company Is Active in Sector', description: 'Evidence the company is active in the sector', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'transitTrainingCert', label: 'Customs Transit Training Certificate', description: 'Training certificate covering all transit professionals', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'employmentContracts', label: 'Employment Contracts — 3 Transit Professionals', description: 'Employment contracts for all three transit professionals', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'educationEvidence', label: 'Education Evidence', description: 'Education evidence for transit professionals', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'workExperienceEvidence', label: 'Work Experience Evidence', description: 'Work experience evidence for transit professionals', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'payrollEvidence', label: 'Three-Month Payroll Evidence', description: 'Payroll evidence for the last three months', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'taxPaymentEvidence', label: 'Monthly Tax Payment Evidence', description: 'Monthly tax payment evidence', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'facilityEvidence', label: 'Vehicle / Machinery / Warehouse Evidence', description: 'Evidence of vehicle, machinery, or warehouse capacity', required: true, icon: IconId },
|
||||||
|
{ key: 'vehicleLibre', label: 'Vehicle Libre / Registration Copy', description: 'Vehicle libre or registration copy', required: true, icon: IconId },
|
||||||
|
{ key: 'rentAgreement', label: 'Legal House / Office Rent Agreement', description: 'Legal house or office rent agreement (or ownership evidence)', required: true, icon: IconId },
|
||||||
|
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Bank confirmation letter for capital', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'capitalBalanceEvidence', label: 'Capital Balance Evidence', description: 'Evidence of capital balance', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for future certificate/ID issuance', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function JointInvestmentLicenseApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [businessAddress, setBusinessAddress] = useState('');
|
||||||
|
const [officeAddress, setOfficeAddress] = useState('');
|
||||||
|
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||||
|
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||||
|
const [contactPersonName, setContactPersonName] = useState('');
|
||||||
|
|
||||||
|
// Step 1 — JV / Ownership Information
|
||||||
|
const [jvType, setJvType] = useState<string | null>(null);
|
||||||
|
const [shareholderName, setShareholderName] = useState('');
|
||||||
|
const [shareholderNationality, setShareholderNationality] = useState('');
|
||||||
|
const [sharePercentage, setSharePercentage] = useState<string | number>('');
|
||||||
|
const [capitalContribution, setCapitalContribution] = useState<string | number>('');
|
||||||
|
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
|
||||||
|
const [beneficialOwnershipDeclaration, setBeneficialOwnershipDeclaration] = useState('');
|
||||||
|
|
||||||
|
// Step 2 — Organizational Experience Information
|
||||||
|
const [organizationalProfile, setOrganizationalProfile] = useState('');
|
||||||
|
const [pastWorkExperienceDescription, setPastWorkExperienceDescription] = useState('');
|
||||||
|
|
||||||
|
// Step 3 — Transit Professional Information
|
||||||
|
const [transiter1Name, setTransiter1Name] = useState('');
|
||||||
|
const [transiter1Position, setTransiter1Position] = useState('');
|
||||||
|
const [transiter2Name, setTransiter2Name] = useState('');
|
||||||
|
const [transiter2Position, setTransiter2Position] = useState('');
|
||||||
|
const [transiter3Name, setTransiter3Name] = useState('');
|
||||||
|
const [transiter3Position, setTransiter3Position] = useState('');
|
||||||
|
|
||||||
|
// Step 4 — Logistics Capacity Information
|
||||||
|
const [vehicleInfo, setVehicleInfo] = useState('');
|
||||||
|
const [machineryInfo, setMachineryInfo] = useState('');
|
||||||
|
const [warehouseInfo, setWarehouseInfo] = useState('');
|
||||||
|
const [cargoFacilityInfo, setCargoFacilityInfo] = useState('');
|
||||||
|
const [logisticsFacilityOwnershipType, setLogisticsFacilityOwnershipType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 5 — Capital Information
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [accountHolderName, setAccountHolderName] = useState('');
|
||||||
|
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||||
|
const [totalCapitalAmount, setTotalCapitalAmount] = useState<string | number>('');
|
||||||
|
|
||||||
|
// Step 6 — Business License and Registration Information
|
||||||
|
const [budgetYear, setBudgetYear] = useState('');
|
||||||
|
const [licenseValidityDate, setLicenseValidityDate] = useState('');
|
||||||
|
|
||||||
|
// Step 7 — Documents
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!companyName.trim() && !!tinNumber.trim() &&
|
||||||
|
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||||
|
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||||
|
);
|
||||||
|
if (active === 1) return (
|
||||||
|
!!jvType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
|
||||||
|
!!sharePercentage && !!capitalContribution && !!beneficialOwnershipDeclaration.trim()
|
||||||
|
);
|
||||||
|
if (active === 2) return !!organizationalProfile.trim() && !!pastWorkExperienceDescription.trim();
|
||||||
|
if (active === 3) return (
|
||||||
|
!!transiter1Name.trim() && !!transiter1Position.trim() &&
|
||||||
|
!!transiter2Name.trim() && !!transiter2Position.trim() &&
|
||||||
|
!!transiter3Name.trim() && !!transiter3Position.trim()
|
||||||
|
);
|
||||||
|
if (active === 4) return !!vehicleInfo.trim() && !!logisticsFacilityOwnershipType;
|
||||||
|
if (active === 5) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && !!totalCapitalAmount;
|
||||||
|
if (active === 6) return !!budgetYear.trim() && !!licenseValidityDate.trim();
|
||||||
|
if (active === 7) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-licenses/joint-investment',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
companyName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||||
|
businessAddress, officeAddress, applicantType, ownershipType, contactPersonName,
|
||||||
|
jvType, shareholderName, shareholderNationality, sharePercentage, capitalContribution,
|
||||||
|
investmentPermitNumber, beneficialOwnershipDeclaration,
|
||||||
|
organizationalProfile, pastWorkExperienceDescription,
|
||||||
|
transiter1Name, transiter1Position, transiter2Name, transiter2Position, transiter3Name, transiter3Position,
|
||||||
|
vehicleInfo, machineryInfo, warehouseInfo, cargoFacilityInfo, logisticsFacilityOwnershipType,
|
||||||
|
bankName, accountHolderName, capitalAmount, totalCapitalAmount,
|
||||||
|
budgetYear, licenseValidityDate,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('Joint Investment / JV Business License application submitted successfully!');
|
||||||
|
navigate('/joint-investment-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Joint Investment / JV Business License Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Company Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Trade Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Contact Person Name (optional)" value={contactPersonName} onChange={(e) => setContactPersonName(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Provide details of the joint venture / joint investment ownership structure.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="JV / Ownership Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<Select label="JV / Joint Investment Type" required data={['Local-Foreign JV', 'Foreign-Foreign JV', 'Local-Local JV']} value={jvType} onChange={setJvType} />
|
||||||
|
<TextInput label="Shareholder / Partner Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Shareholder / Partner Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
|
||||||
|
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
|
||||||
|
<NumberInput label="Capital Contribution (ETB)" required min={0} value={capitalContribution} onChange={setCapitalContribution} />
|
||||||
|
<TextInput label="Investment Permit Number (if applicable)" value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Textarea
|
||||||
|
label="Beneficial Ownership Declaration"
|
||||||
|
required
|
||||||
|
placeholder="Describe the beneficial owners of the company..."
|
||||||
|
value={beneficialOwnershipDeclaration}
|
||||||
|
onChange={(e) => setBeneficialOwnershipDeclaration(e.currentTarget.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Organizational Experience Information" />
|
||||||
|
<Textarea
|
||||||
|
label="Organizational Profile"
|
||||||
|
required
|
||||||
|
placeholder="Describe the organization's structure, history, and prior work..."
|
||||||
|
value={organizationalProfile}
|
||||||
|
onChange={(e) => setOrganizationalProfile(e.currentTarget.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Past Work Experience Description"
|
||||||
|
required
|
||||||
|
placeholder="Describe relevant past work experience..."
|
||||||
|
value={pastWorkExperienceDescription}
|
||||||
|
onChange={(e) => setPastWorkExperienceDescription(e.currentTarget.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
At least three transit professionals/transiters are required.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Transit Professional 1" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Full Name" required value={transiter1Name} onChange={(e) => setTransiter1Name(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Position" required value={transiter1Position} onChange={(e) => setTransiter1Position(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Transit Professional 2" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Full Name" required value={transiter2Name} onChange={(e) => setTransiter2Name(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Position" required value={transiter2Position} onChange={(e) => setTransiter2Position(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Transit Professional 3" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Full Name" required value={transiter3Name} onChange={(e) => setTransiter3Name(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Position" required value={transiter3Position} onChange={(e) => setTransiter3Position(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Logistics Capacity Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Vehicle Information" required value={vehicleInfo} onChange={(e) => setVehicleInfo(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Machinery Information (if applicable)" value={machineryInfo} onChange={(e) => setMachineryInfo(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Warehouse Information (if applicable)" value={warehouseInfo} onChange={(e) => setWarehouseInfo(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Cargo Receiving Facility Information (if applicable)" value={cargoFacilityInfo} onChange={(e) => setCargoFacilityInfo(e.currentTarget.value)} />
|
||||||
|
<Select label="Logistics Facility Ownership Type" required data={['Owned', 'Rented']} value={logisticsFacilityOwnershipType} onChange={setLogisticsFacilityOwnershipType} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 5 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
The capital amount is subject to a minimum threshold configured by EMA. No fixed threshold is enforced here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Capital Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||||
|
<NumberInput label="Capital Amount (ETB)" required min={0} value={capitalAmount} onChange={setCapitalAmount} />
|
||||||
|
<NumberInput label="Total Capital Amount (ETB)" required min={0} value={totalCapitalAmount} onChange={setTotalCapitalAmount} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 6 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Business License and Registration Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Budget Year" required value={budgetYear} onChange={(e) => setBudgetYear(e.currentTarget.value)} />
|
||||||
|
<TextInput label="License Validity Date" required placeholder="YYYY-MM-DD" value={licenseValidityDate} onChange={(e) => setLicenseValidityDate(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 7 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 8 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||||
|
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||||
|
<ReviewRow label="Business Address" value={businessAddress} />
|
||||||
|
<ReviewRow label="Office Address" value={officeAddress} />
|
||||||
|
<ReviewRow label="Contact Person" value={contactPersonName} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">JV / Ownership Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="JV Type" value={jvType ?? ''} />
|
||||||
|
<ReviewRow label="Shareholder / Partner" value={shareholderName} />
|
||||||
|
<ReviewRow label="Nationality" value={shareholderNationality} />
|
||||||
|
<ReviewRow label="Share Percentage" value={sharePercentage ? `${sharePercentage}%` : ''} />
|
||||||
|
<ReviewRow label="Capital Contribution" value={capitalContribution ? `${Number(capitalContribution).toLocaleString()} ETB` : ''} />
|
||||||
|
<ReviewRow label="Investment Permit No." value={investmentPermitNumber} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Transit Professionals</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Transit Professional 1" value={`${transiter1Name} — ${transiter1Position}`} />
|
||||||
|
<ReviewRow label="Transit Professional 2" value={`${transiter2Name} — ${transiter2Position}`} />
|
||||||
|
<ReviewRow label="Transit Professional 3" value={`${transiter3Name} — ${transiter3Position}`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Capital Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Bank Name" value={bankName} />
|
||||||
|
<ReviewRow label="Capital Amount" value={capitalAmount ? `${Number(capitalAmount).toLocaleString()} ETB` : ''} />
|
||||||
|
<ReviewRow label="Total Capital Amount" value={totalCapitalAmount ? `${Number(totalCapitalAmount).toLocaleString()} ETB` : ''} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/joint-investment-license') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconBuildingBank size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconBuildingBank,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Completed';
|
||||||
|
|
||||||
|
interface JointInvestmentApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
Completed: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JointInvestmentLicensePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<JointInvestmentApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<JointInvestmentApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-licenses/joint-investment/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Joint Investment / JV Business License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Apply for and manage your Joint Investment / JV Business License</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconBuildingBank size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for a Joint Investment / JV Business License</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Provide company, JV/ownership, organizational, transit, logistics, and capital information</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Company establishment and JV/ownership documentation" />
|
||||||
|
<RequirementItem label="Organizational profile and past work experience evidence" />
|
||||||
|
<RequirementItem label="At least three qualified transit professionals" />
|
||||||
|
<RequirementItem label="Vehicle, machinery, or warehouse evidence as applicable" />
|
||||||
|
<RequirementItem label="Bank confirmation letter and capital evidence" />
|
||||||
|
<RequirementItem label="Passport-size photo for future certificate issuance" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconBuildingBank size={18} />} onClick={() => navigate('/joint-investment-license/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About Joint Investment / JV Business Licensing</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
This is a one-time evaluation license. Joint Investment / JV Business applications do not
|
||||||
|
require annual renewal once a decision is reached.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconBuildingBank size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Completed' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Completed'].includes(application.status) },
|
||||||
|
{ label: 'Completed', done: (application.status as LicenseStatus) === 'Completed' },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application.status === 'Completed' && (
|
||||||
|
<Alert color="teal" icon={<IconCheck size={17} />}>
|
||||||
|
Your Joint Investment / JV Business License application is complete. The decision and audit
|
||||||
|
history have been recorded.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Alert, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||||
|
import { IconArrowLeft, IconInfoCircle, IconBuildingBank } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export function JointInvestmentLicenseRenewalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Joint Investment / JV Business License Renewal</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Renewal information for your Joint Investment / JV Business License</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} title="Renewal Not Applicable">
|
||||||
|
The Joint Investment / JV Business License is issued as a one-time evaluation and does not
|
||||||
|
require annual renewal. Once your application is approved and completed, no further renewal
|
||||||
|
action is needed for this license type.
|
||||||
|
</Alert>
|
||||||
|
<Button mt="lg" leftSection={<IconArrowLeft size={16} />} onClick={() => navigate('/joint-investment-license')}>
|
||||||
|
Back to Joint Investment License
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconShieldOff,
|
||||||
|
IconShip,
|
||||||
|
IconStack2,
|
||||||
|
IconTruck,
|
||||||
|
IconUsers,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued' | 'Completed';
|
||||||
|
|
||||||
|
interface LicenseSummary {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
expiryDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green', Completed: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LICENSE_LINES = [
|
||||||
|
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', apiPath: '/logistics-licenses/freight-forwarder/my', icon: IconTruck, color: 'blue' },
|
||||||
|
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', apiPath: '/logistics-licenses/shipping-agent/my', icon: IconShip, color: 'indigo' },
|
||||||
|
{ key: 'combined', label: 'Combined License', route: '/combined-license', apiPath: '/logistics-licenses/combined/my', icon: IconStack2, color: 'violet' },
|
||||||
|
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', apiPath: '/logistics-licenses/joint-investment/my', icon: IconUsers, color: 'grape' },
|
||||||
|
{ key: 'mto', label: 'MTO License', route: '/mto-license', apiPath: '/logistics-licenses/mto/my', icon: IconTruck, color: 'cyan' },
|
||||||
|
{ key: 'waiver', label: 'Waiver', route: '/waiver', apiPath: '/logistics-licenses/waiver/my', icon: IconShieldOff, color: 'orange' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Resubmit Required', 'Payment Pending', 'Payment Confirmed']);
|
||||||
|
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
|
||||||
|
|
||||||
|
export function LogisticsDashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [apps, setApps] = useState<Record<string, LicenseSummary | null>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchTrigger] = useApiMutation<LicenseSummary>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
|
||||||
|
Promise.allSettled(
|
||||||
|
LICENSE_LINES.map((line) => fetchTrigger({ url: line.apiPath, method: 'GET' }).unwrap())
|
||||||
|
).then((results) => {
|
||||||
|
const next: Record<string, LicenseSummary | null> = {};
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
next[LICENSE_LINES[i].key] = r.status === 'fulfilled' ? r.value : null;
|
||||||
|
});
|
||||||
|
setApps(next);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
const values = Object.values(apps).filter((a): a is LicenseSummary => !!a);
|
||||||
|
const stats = {
|
||||||
|
total: values.length,
|
||||||
|
active: values.filter((a) => ACTIVE_STATUSES.has(a.status)).length,
|
||||||
|
issued: values.filter((a) => ISSUED_STATUSES.has(a.status)).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
|
||||||
|
<IconStack2 size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>My Logistics Licenses Dashboard</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Track your freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Skeleton height={80} radius="md" />
|
||||||
|
<Skeleton height={80} radius="md" />
|
||||||
|
<Skeleton height={80} radius="md" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Applications Filed</Text>
|
||||||
|
<Text fz="xl" fw={700} c="indigo.6" mt={4}>{stats.total}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Active / In Progress</Text>
|
||||||
|
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.active}</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificates Issued</Text>
|
||||||
|
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.issued}</Text>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Text fw={700} mb="md">License Applications</Text>
|
||||||
|
<Stack gap={0}>
|
||||||
|
{LICENSE_LINES.map((line, i) => {
|
||||||
|
const app = apps[line.key];
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={line.key}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
borderBottom: i < LICENSE_LINES.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(app ? line.route : `${line.route}/apply`)}
|
||||||
|
>
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={36} radius="md" color={line.color} variant="light">
|
||||||
|
<line.icon size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={600}>{line.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{app ? app.id : 'Not applied yet'}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton height={22} width={90} radius="xl" />
|
||||||
|
) : app ? (
|
||||||
|
<Badge variant="light" color={STATUS_COLOR[app.status] ?? 'gray'} radius="sm">
|
||||||
|
{app.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button size="xs" variant="light">Apply</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconBuildingWarehouse,
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconShip,
|
||||||
|
IconTruck,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Company Information' },
|
||||||
|
{ label: 'Ownership Information' },
|
||||||
|
{ label: 'Management & Board' },
|
||||||
|
{ label: 'Employee Information' },
|
||||||
|
{ label: 'Terminal & Facility' },
|
||||||
|
{ label: 'Fleet & Equipment' },
|
||||||
|
{ label: 'Financial Capacity' },
|
||||||
|
{ label: 'Insurance, Bond & Liability' },
|
||||||
|
{ label: 'Agent Network & ICT' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'businessLicense', label: 'Business License / Trade Registration', description: 'Valid business license', required: true, icon: IconId },
|
||||||
|
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'memorandum', label: 'Memorandum and Articles of Association', description: 'Company formation documents', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'investmentPermit', label: 'Investment Permit', description: 'Required for foreign or joint venture ownership', required: false, icon: IconFileDescription },
|
||||||
|
{ key: 'capitalProof', label: 'Paid-up Capital Proof', description: 'Evidence of paid-up capital', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Letter confirming capital deposit', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'boardCvs', label: 'Board Member CVs', description: 'CVs of all board members', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'managerCv', label: 'Manager CV and Qualification Documents', description: 'General Manager CV and qualifications', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'employeeQualDocs', label: 'Employee Qualification Documents', description: 'Qualification documents for key employees', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'terminalLease', label: 'Terminal Lease or Title Document', description: 'Terminal ownership or lease document', required: true, icon: IconBuildingWarehouse },
|
||||||
|
{ key: 'warehouseEvidence', label: 'Warehouse / Terminal Evidence', description: 'Photos or inspection evidence of warehouse/terminal', required: false, icon: IconBuildingWarehouse },
|
||||||
|
{ key: 'truckOwnership', label: 'Truck Ownership or Rental Agreement', description: 'Vehicle registration or rental contract', required: true, icon: IconTruck },
|
||||||
|
{ key: 'equipmentOwnership', label: 'Equipment Ownership or Rental Agreement', description: 'Required if equipment is rented', required: false, icon: IconTruck },
|
||||||
|
{ key: 'insuranceCert', label: 'Insurance Certificate', description: 'Cargo and multimodal transport liability insurance', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'customsBond', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'agentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'branchContract', label: 'Branch Office Contract', description: 'Branch office lease or ownership contract', required: false, icon: IconFileDescription },
|
||||||
|
{ key: 'operationalManual', label: 'Administrative / Operational Manual', description: 'Operational procedures manual', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'ictEvidence', label: 'ICT Capability Evidence', description: 'Evidence of ICT / tracking system capability', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MtoLicenseApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 0 — Company / Applicant Information (email/phone auto-fetched from account, not shown here)
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tradeName, setTradeName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||||
|
const [ownershipTypeCompany, setOwnershipTypeCompany] = useState<string | null>(null);
|
||||||
|
const [businessAddress, setBusinessAddress] = useState('');
|
||||||
|
const [headOfficeAddress, setHeadOfficeAddress] = useState('');
|
||||||
|
const [hasBranchOffices, setHasBranchOffices] = useState<string | null>(null);
|
||||||
|
const [branchOfficeAddress, setBranchOfficeAddress] = useState('');
|
||||||
|
|
||||||
|
// Step 1 — Ownership Information
|
||||||
|
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||||
|
const [shareholderName, setShareholderName] = useState('');
|
||||||
|
const [shareholderNationality, setShareholderNationality] = useState('');
|
||||||
|
const [sharePercentage, setSharePercentage] = useState<string | number>('');
|
||||||
|
const [investmentClassification, setInvestmentClassification] = useState<string | null>(null);
|
||||||
|
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
|
||||||
|
const [beneficialOwnershipDeclared, setBeneficialOwnershipDeclared] = useState(false);
|
||||||
|
|
||||||
|
// Step 2 — Management and Board Information
|
||||||
|
const [gmName, setGmName] = useState('');
|
||||||
|
const [gmQualification, setGmQualification] = useState('');
|
||||||
|
const [gmExperience, setGmExperience] = useState('');
|
||||||
|
const [boardMembers, setBoardMembers] = useState('');
|
||||||
|
const [gmCv, setGmCv] = useState<File | null>(null);
|
||||||
|
const [gmEducationCert, setGmEducationCert] = useState<File | null>(null);
|
||||||
|
const [gmExperienceEvidence, setGmExperienceEvidence] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Step 3 — Employee Information
|
||||||
|
const [empFullName, setEmpFullName] = useState('');
|
||||||
|
const [empPosition, setEmpPosition] = useState('');
|
||||||
|
const [empQualification, setEmpQualification] = useState('');
|
||||||
|
const [empExperience, setEmpExperience] = useState('');
|
||||||
|
const [empEmploymentType, setEmpEmploymentType] = useState<string | null>(null);
|
||||||
|
const [empProfCert, setEmpProfCert] = useState<File | null>(null);
|
||||||
|
const [empContract, setEmpContract] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Step 4 — Terminal and Facility Information
|
||||||
|
const [terminalLocation, setTerminalLocation] = useState('');
|
||||||
|
const [terminalSize, setTerminalSize] = useState('');
|
||||||
|
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||||
|
const [terminalLeaseDoc, setTerminalLeaseDoc] = useState<File | null>(null);
|
||||||
|
const [warehouseAvailability, setWarehouseAvailability] = useState<string | null>(null);
|
||||||
|
const [warehouseSize, setWarehouseSize] = useState('');
|
||||||
|
const [terminalSecurityInfo, setTerminalSecurityInfo] = useState('');
|
||||||
|
const [terminalPhotos, setTerminalPhotos] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Step 5 — Fleet and Equipment Information
|
||||||
|
const [numberOfTrucks, setNumberOfTrucks] = useState<string | number>('');
|
||||||
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
|
const [truckCapacity, setTruckCapacity] = useState('');
|
||||||
|
const [truckOwnership, setTruckOwnership] = useState<string | null>(null);
|
||||||
|
const [vehicleRegDoc, setVehicleRegDoc] = useState<File | null>(null);
|
||||||
|
const [vehicleRentalContract, setVehicleRentalContract] = useState<File | null>(null);
|
||||||
|
const [cargoHandlingEquipment, setCargoHandlingEquipment] = useState('');
|
||||||
|
const [equipmentOwnership, setEquipmentOwnership] = useState<string | null>(null);
|
||||||
|
const [equipmentEvidence, setEquipmentEvidence] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Step 6 — Financial Capacity
|
||||||
|
const [paidUpCapitalAmount, setPaidUpCapitalAmount] = useState<string | number>('');
|
||||||
|
const [bankConfirmation, setBankConfirmation] = useState<File | null>(null);
|
||||||
|
const [bankDepositEvidence, setBankDepositEvidence] = useState<File | null>(null);
|
||||||
|
const [auditedFinancialStatement, setAuditedFinancialStatement] = useState<File | null>(null);
|
||||||
|
const [assetValuationEvidence, setAssetValuationEvidence] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Step 7 — Insurance, Bond, and Liability
|
||||||
|
const [cargoLiabilityInsurance, setCargoLiabilityInsurance] = useState<File | null>(null);
|
||||||
|
const [mtoLiabilityInsurance, setMtoLiabilityInsurance] = useState<File | null>(null);
|
||||||
|
const [customsBondDoc, setCustomsBondDoc] = useState<File | null>(null);
|
||||||
|
const [insuranceValidityDate, setInsuranceValidityDate] = useState('');
|
||||||
|
const [bondValidityDate, setBondValidityDate] = useState('');
|
||||||
|
|
||||||
|
// Step 8 — Agent Network and ICT Capability
|
||||||
|
const [overseasAgentAgreement, setOverseasAgentAgreement] = useState<File | null>(null);
|
||||||
|
const [localBranchInfo, setLocalBranchInfo] = useState('');
|
||||||
|
const [ictSystemDescription, setIctSystemDescription] = useState('');
|
||||||
|
const [cargoTrackingCapability, setCargoTrackingCapability] = useState('');
|
||||||
|
const [communicationSystem, setCommunicationSystem] = useState('');
|
||||||
|
const [documentManagementCapability, setDocumentManagementCapability] = useState('');
|
||||||
|
|
||||||
|
// Step 9 — Documents
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const isForeignOrJoint = investmentClassification === 'Foreign' || investmentClassification === 'Joint';
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||||
|
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||||
|
!!applicantType && !!ownershipTypeCompany &&
|
||||||
|
!!businessAddress.trim() && !!headOfficeAddress.trim() &&
|
||||||
|
!!hasBranchOffices && (hasBranchOffices === 'No' || !!branchOfficeAddress.trim())
|
||||||
|
);
|
||||||
|
if (active === 1) return (
|
||||||
|
!!ownershipType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
|
||||||
|
!!sharePercentage && !!investmentClassification &&
|
||||||
|
(!isForeignOrJoint || !!investmentPermitNumber.trim()) &&
|
||||||
|
beneficialOwnershipDeclared
|
||||||
|
);
|
||||||
|
if (active === 2) return (
|
||||||
|
!!gmName.trim() && !!gmQualification.trim() && !!gmExperience.trim() && !!boardMembers.trim() &&
|
||||||
|
!!gmCv && !!gmEducationCert && !!gmExperienceEvidence
|
||||||
|
);
|
||||||
|
if (active === 3) return (
|
||||||
|
!!empFullName.trim() && !!empPosition.trim() && !!empQualification.trim() &&
|
||||||
|
!!empExperience.trim() && !!empEmploymentType
|
||||||
|
);
|
||||||
|
if (active === 4) return (
|
||||||
|
!!terminalLocation.trim() && !!terminalSize.trim() && !!terminalOwnership && !!terminalLeaseDoc &&
|
||||||
|
!!warehouseAvailability &&
|
||||||
|
(warehouseAvailability === 'No' || (!!warehouseSize.trim() && !!terminalSecurityInfo.trim() && !!terminalPhotos))
|
||||||
|
);
|
||||||
|
if (active === 5) return (
|
||||||
|
!!numberOfTrucks && !!truckPlateNumber.trim() && !!truckCapacity.trim() && !!truckOwnership && !!vehicleRegDoc &&
|
||||||
|
(truckOwnership !== 'Rented' || !!vehicleRentalContract) &&
|
||||||
|
!!cargoHandlingEquipment.trim() && !!equipmentOwnership &&
|
||||||
|
(equipmentOwnership !== 'Rented' || !!equipmentEvidence)
|
||||||
|
);
|
||||||
|
if (active === 6) return (
|
||||||
|
!!paidUpCapitalAmount && !!bankConfirmation && !!bankDepositEvidence
|
||||||
|
);
|
||||||
|
if (active === 7) return (
|
||||||
|
!!cargoLiabilityInsurance && !!mtoLiabilityInsurance && !!customsBondDoc &&
|
||||||
|
!!insuranceValidityDate.trim() && !!bondValidityDate.trim()
|
||||||
|
);
|
||||||
|
if (active === 8) return (
|
||||||
|
!!overseasAgentAgreement && !!localBranchInfo.trim() && !!ictSystemDescription.trim() &&
|
||||||
|
!!cargoTrackingCapability.trim() && !!communicationSystem.trim() && !!documentManagementCapability.trim()
|
||||||
|
);
|
||||||
|
if (active === 9) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]) &&
|
||||||
|
(!isForeignOrJoint || !!files['investmentPermit']);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-licenses/mto',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||||
|
applicantType, ownershipTypeCompany, businessAddress, headOfficeAddress,
|
||||||
|
hasBranchOffices, branchOfficeAddress,
|
||||||
|
ownershipType, shareholderName, shareholderNationality, sharePercentage,
|
||||||
|
investmentClassification, investmentPermitNumber, beneficialOwnershipDeclared,
|
||||||
|
gmName, gmQualification, gmExperience, boardMembers,
|
||||||
|
empFullName, empPosition, empQualification, empExperience, empEmploymentType,
|
||||||
|
terminalLocation, terminalSize, terminalOwnership, warehouseAvailability, warehouseSize, terminalSecurityInfo,
|
||||||
|
numberOfTrucks, truckPlateNumber, truckCapacity, truckOwnership, cargoHandlingEquipment, equipmentOwnership,
|
||||||
|
paidUpCapitalAmount,
|
||||||
|
insuranceValidityDate, bondValidityDate,
|
||||||
|
localBranchInfo, ictSystemDescription, cargoTrackingCapability, communicationSystem, documentManagementCapability,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('MTO License application submitted successfully!');
|
||||||
|
navigate('/mto-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>MTO License Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Company / Applicant Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Operator Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Commercial Registration No." required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License No." required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipTypeCompany} onChange={setOwnershipTypeCompany} />
|
||||||
|
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Head Office Address" required value={headOfficeAddress} onChange={(e) => setHeadOfficeAddress(e.currentTarget.value)} />
|
||||||
|
<Select label="Has Branch Offices?" required data={['Yes', 'No']} value={hasBranchOffices} onChange={setHasBranchOffices} />
|
||||||
|
{hasBranchOffices === 'Yes' && (
|
||||||
|
<TextInput label="Branch Office Address" required value={branchOfficeAddress} onChange={(e) => setBranchOfficeAddress(e.currentTarget.value)} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Ownership Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Ownership Type" required data={['Sole', 'Partnership', 'Shareholding Company']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<TextInput label="Shareholder Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Shareholder Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
|
||||||
|
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
|
||||||
|
<Select label="Investment Classification" required data={['Local', 'Foreign', 'Joint']} value={investmentClassification} onChange={setInvestmentClassification} />
|
||||||
|
{isForeignOrJoint && (
|
||||||
|
<TextInput label="Investment Permit Number" required value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Checkbox
|
||||||
|
mt="sm"
|
||||||
|
label="I declare the beneficial ownership information provided above is accurate and complete."
|
||||||
|
checked={beneficialOwnershipDeclared}
|
||||||
|
onChange={(e) => setBeneficialOwnershipDeclared(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="General Manager" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="General Manager Name" required value={gmName} onChange={(e) => setGmName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="General Manager Qualification" required value={gmQualification} onChange={(e) => setGmQualification(e.currentTarget.value)} />
|
||||||
|
<TextInput label="General Manager Experience" required value={gmExperience} onChange={(e) => setGmExperience(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Board of Directors" />
|
||||||
|
<Textarea
|
||||||
|
label="Board Member List"
|
||||||
|
description="List each board member's name and role"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={boardMembers}
|
||||||
|
onChange={(e) => setBoardMembers(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SectionHead title="Supporting Documents" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'gmCv', label: 'CV Upload', description: 'General Manager CV', required: true, icon: IconFileDescription }} file={gmCv} onFile={setGmCv} />
|
||||||
|
<DocCard slot={{ key: 'gmEducationCert', label: 'Education Certificate Upload', description: 'General Manager education certificate', required: true, icon: IconFileDescription }} file={gmEducationCert} onFile={setGmEducationCert} />
|
||||||
|
<DocCard slot={{ key: 'gmExperienceEvidence', label: 'Experience Evidence Upload', description: 'Proof of relevant work experience', required: true, icon: IconFileDescription }} file={gmExperienceEvidence} onFile={setGmExperienceEvidence} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Key Employee Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Full Name" required value={empFullName} onChange={(e) => setEmpFullName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Position" required value={empPosition} onChange={(e) => setEmpPosition(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Qualification" required value={empQualification} onChange={(e) => setEmpQualification(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Experience" required value={empExperience} onChange={(e) => setEmpExperience(e.currentTarget.value)} />
|
||||||
|
<Select label="Employment Type" required data={['Permanent', 'Contract', 'Part-Time']} value={empEmploymentType} onChange={setEmpEmploymentType} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Employee Documents" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'empProfCert', label: 'Professional Certificate', description: 'Optional professional certification', required: false, icon: IconFileDescription }} file={empProfCert} onFile={setEmpProfCert} />
|
||||||
|
<DocCard slot={{ key: 'empContract', label: 'Employment Contract', description: 'Optional employment contract document', required: false, icon: IconFileDescription }} file={empContract} onFile={setEmpContract} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Terminal Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Terminal Location" required value={terminalLocation} onChange={(e) => setTerminalLocation(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Terminal Size" required value={terminalSize} onChange={(e) => setTerminalSize(e.currentTarget.value)} />
|
||||||
|
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||||
|
<DocCard slot={{ key: 'terminalLeaseDoc', label: 'Terminal Lease / Title Document', description: 'Ownership or lease document for the terminal', required: true, icon: IconBuildingWarehouse }} file={terminalLeaseDoc} onFile={setTerminalLeaseDoc} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Warehouse & Security" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Warehouse Availability" required data={['Yes', 'No']} value={warehouseAvailability} onChange={setWarehouseAvailability} />
|
||||||
|
{warehouseAvailability === 'Yes' && (
|
||||||
|
<TextInput label="Warehouse Size" required value={warehouseSize} onChange={(e) => setWarehouseSize(e.currentTarget.value)} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
{warehouseAvailability === 'Yes' && (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
label="Terminal Security / Fence Information"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={terminalSecurityInfo}
|
||||||
|
onChange={(e) => setTerminalSecurityInfo(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'terminalPhotos', label: 'Terminal Photos / Inspection Evidence', description: 'Photos or evidence of terminal facility', required: true, icon: IconCamera }} file={terminalPhotos} onFile={setTerminalPhotos} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 5 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Fleet Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<NumberInput label="Number of Trucks" required min={0} value={numberOfTrucks} onChange={setNumberOfTrucks} />
|
||||||
|
<TextInput label="Truck Plate Number" required value={truckPlateNumber} onChange={(e) => setTruckPlateNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Truck Capacity" required value={truckCapacity} onChange={(e) => setTruckCapacity(e.currentTarget.value)} />
|
||||||
|
<Select label="Truck Ownership Type" required data={['Owned', 'Rented']} value={truckOwnership} onChange={setTruckOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||||
|
<DocCard slot={{ key: 'vehicleRegDoc', label: 'Vehicle Registration Document', description: 'Registration document for the fleet', required: true, icon: IconTruck }} file={vehicleRegDoc} onFile={setVehicleRegDoc} />
|
||||||
|
{truckOwnership === 'Rented' && (
|
||||||
|
<DocCard slot={{ key: 'vehicleRentalContract', label: 'Vehicle Rental Contract', description: 'Rental contract for the fleet', required: true, icon: IconTruck }} file={vehicleRentalContract} onFile={setVehicleRentalContract} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Cargo Handling Equipment" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Cargo Handling Equipment" required value={cargoHandlingEquipment} onChange={(e) => setCargoHandlingEquipment(e.currentTarget.value)} />
|
||||||
|
<Select label="Equipment Ownership / Rental" required data={['Owned', 'Rented']} value={equipmentOwnership} onChange={setEquipmentOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
{equipmentOwnership === 'Rented' && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'equipmentEvidence', label: 'Equipment Ownership / Rental Evidence', description: 'Ownership or rental evidence for equipment', required: true, icon: IconTruck }} file={equipmentEvidence} onFile={setEquipmentEvidence} />
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 6 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Financial Capacity" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<NumberInput label="Paid-up Capital Amount (ETB)" required min={0} value={paidUpCapitalAmount} onChange={setPaidUpCapitalAmount} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||||
|
<DocCard slot={{ key: 'bankConfirmation', label: 'Bank Confirmation', description: 'Bank letter confirming capital', required: true, icon: IconFileDescription }} file={bankConfirmation} onFile={setBankConfirmation} />
|
||||||
|
<DocCard slot={{ key: 'bankDepositEvidence', label: 'Bank Deposit Evidence', description: 'Evidence of the capital deposit', required: true, icon: IconFileDescription }} file={bankDepositEvidence} onFile={setBankDepositEvidence} />
|
||||||
|
<DocCard slot={{ key: 'auditedFinancialStatement', label: 'Audited Financial Statement', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={auditedFinancialStatement} onFile={setAuditedFinancialStatement} />
|
||||||
|
<DocCard slot={{ key: 'assetValuationEvidence', label: 'Asset Valuation Evidence', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={assetValuationEvidence} onFile={setAssetValuationEvidence} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 7 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Insurance, Bond & Liability" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'cargoLiabilityInsurance', label: 'Cargo Liability Insurance', description: 'Insurance covering cargo liability', required: true, icon: IconShieldCheck }} file={cargoLiabilityInsurance} onFile={setCargoLiabilityInsurance} />
|
||||||
|
<DocCard slot={{ key: 'mtoLiabilityInsurance', label: 'Multimodal Transport Liability Insurance', description: 'Insurance covering multimodal transport liability', required: true, icon: IconShieldCheck }} file={mtoLiabilityInsurance} onFile={setMtoLiabilityInsurance} />
|
||||||
|
<DocCard slot={{ key: 'customsBondDoc', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck }} file={customsBondDoc} onFile={setCustomsBondDoc} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||||
|
<TextInput label="Insurance Validity Date" required placeholder="YYYY-MM-DD" value={insuranceValidityDate} onChange={(e) => setInsuranceValidityDate(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Bond Validity Date" required placeholder="YYYY-MM-DD" value={bondValidityDate} onChange={(e) => setBondValidityDate(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 8 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Agent Network" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<DocCard slot={{ key: 'overseasAgentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription }} file={overseasAgentAgreement} onFile={setOverseasAgentAgreement} />
|
||||||
|
<TextInput label="Local Branch Office Information" required value={localBranchInfo} onChange={(e) => setLocalBranchInfo(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="ICT Capability" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Cargo Tracking Capability" required value={cargoTrackingCapability} onChange={(e) => setCargoTrackingCapability(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Communication System" required value={communicationSystem} onChange={(e) => setCommunicationSystem(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Document Management Capability" required value={documentManagementCapability} onChange={(e) => setDocumentManagementCapability(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Textarea
|
||||||
|
label="ICT System Description"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={ictSystemDescription}
|
||||||
|
onChange={(e) => setIctSystemDescription(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 9 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 10 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting. Your application requires a physical inspection of your terminal, warehouse, trucks, office, and equipment.
|
||||||
|
If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Company / Applicant Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="Trade Name" value={tradeName} />
|
||||||
|
<ReviewRow label="TIN" value={tinNumber} />
|
||||||
|
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||||
|
<ReviewRow label="Business License No." value={businessLicenseNumber} />
|
||||||
|
<ReviewRow label="Applicant Type" value={applicantType ?? ''} />
|
||||||
|
<ReviewRow label="Ownership Type" value={ownershipTypeCompany ?? ''} />
|
||||||
|
<ReviewRow label="Business Address" value={businessAddress} />
|
||||||
|
<ReviewRow label="Head Office Address" value={headOfficeAddress} />
|
||||||
|
{hasBranchOffices === 'Yes' && <ReviewRow label="Branch Office Address" value={branchOfficeAddress} />}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Ownership Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Ownership Type" value={ownershipType ?? ''} />
|
||||||
|
<ReviewRow label="Shareholder Name" value={shareholderName} />
|
||||||
|
<ReviewRow label="Shareholder Nationality" value={shareholderNationality} />
|
||||||
|
<ReviewRow label="Share Percentage" value={`${sharePercentage || ''}%`} />
|
||||||
|
<ReviewRow label="Investment Classification" value={investmentClassification ?? ''} />
|
||||||
|
{isForeignOrJoint && <ReviewRow label="Investment Permit Number" value={investmentPermitNumber} />}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Management, Employees & Terminal</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="General Manager" value={gmName} />
|
||||||
|
<ReviewRow label="Employee" value={empFullName} />
|
||||||
|
<ReviewRow label="Terminal Location" value={terminalLocation} />
|
||||||
|
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Warehouse Availability" value={warehouseAvailability ?? ''} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Fleet & Financial Capacity</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Number of Trucks" value={`${numberOfTrucks || ''}`} />
|
||||||
|
<ReviewRow label="Truck Ownership" value={truckOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Paid-up Capital" value={`${Number(paidUpCapitalAmount || 0).toLocaleString()} ETB`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/mto-license') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCertificate,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconDownload,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Inspection Pending' | 'Inspection Completed' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||||
|
|
||||||
|
interface MtoApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
'Inspection Pending': 'grape', 'Inspection Completed': 'indigo',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MtoLicensePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<MtoApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<MtoApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-licenses/mto/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Multimodal Transport Operator License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Apply for and manage your Multimodal Transport Operator (MTO) License</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for an MTO License</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Provide company, ownership, management, terminal, fleet, financial, and insurance information</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Company registration, TIN, and business license documents" />
|
||||||
|
<RequirementItem label="Ownership and beneficial ownership declaration" />
|
||||||
|
<RequirementItem label="Management and board information with CVs" />
|
||||||
|
<RequirementItem label="Terminal / warehouse lease or title documents" />
|
||||||
|
<RequirementItem label="Fleet and cargo handling equipment documentation" />
|
||||||
|
<RequirementItem label="Proof of paid-up capital and bank confirmation" />
|
||||||
|
<RequirementItem label="Cargo and multimodal transport liability insurance, customs bond" />
|
||||||
|
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/mto-license/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About MTO Licensing</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Applications require a physical inspection of your terminal, warehouse, trucks, office, and equipment.
|
||||||
|
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||||
|
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Inspection Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Inspection Pending">
|
||||||
|
A physical inspection of your terminal, warehouse, trucks, office, and equipment is required. An Inspector will contact you to schedule the visit.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Inspection Completed' && (
|
||||||
|
<Alert icon={<IconCircleCheck size={17} />} color="indigo" title="Inspection Completed">
|
||||||
|
Your physical inspection has been completed by an Inspector. Your application is proceeding to the next stage.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Payment Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||||
|
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||||
|
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Certificate Issued' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' && application.status !== 'Under Review' },
|
||||||
|
{ label: 'Inspection Pending', done: ['Inspection Pending', 'Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Inspection Completed', done: ['Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application.status === 'Certificate Issued' && (
|
||||||
|
<div>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||||
|
</Group>
|
||||||
|
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||||
|
Your Multimodal Transport Operator License certificate is ready. Valid until {application.expiryDate}.
|
||||||
|
</Alert>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">Multimodal Transport Operator License Certificate</Text>
|
||||||
|
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||||
|
Download Certificate
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { FileButton } from '@mantine/core';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RENEWAL_DOCS: DocSlot[] = [
|
||||||
|
{ key: 'prevCertificate', label: 'Previous MTO License Certificate', description: 'Your current/expiring certificate', required: true },
|
||||||
|
{ key: 'taxPaper', label: 'Tax Paper', description: 'Current tax clearance document', required: true },
|
||||||
|
{ key: 'payroll', label: 'Employee Payroll', description: 'Payroll evidence for employees', required: true },
|
||||||
|
{ key: 'updatedEmployeeList', label: 'Updated Employee List', description: 'Current list of employees', required: true },
|
||||||
|
{ key: 'updatedInsurance', label: 'Updated Insurance Certificate', description: 'Current insurance certificate', required: true },
|
||||||
|
{ key: 'customsBondRenewal', label: 'Customs Bond Renewal', description: 'Required only if the customs bond has been renewed', required: false },
|
||||||
|
{ key: 'terminalLeaseRenewal', label: 'Terminal Lease / Rent Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||||
|
{ key: 'truckRentalRenewal', label: 'Truck / Car Rental Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||||
|
{ key: 'equipmentRentalRenewal', label: 'Equipment Rental Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||||
|
{ key: 'branchOfficeContract', label: 'Branch Office Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||||
|
{ key: 'complianceDeclaration', label: 'Compliance Declaration', description: 'Signed compliance declaration', required: true },
|
||||||
|
{ key: 'paymentReceipt', label: 'Payment Receipt', description: 'Receipt of renewal service payment, if already paid', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MtoLicenseRenewalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({ url: '/logistics-licenses/mto/renew', method: 'POST', body: {} }).unwrap();
|
||||||
|
notify.success('Renewal request submitted successfully!');
|
||||||
|
navigate('/mto-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Renewal submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Renew MTO License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
If your terminal, truck, equipment, or branch office contract has expired since your last submission, an updated contract is required.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{RENEWAL_DOCS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit Renewal Request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCamera,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldCheck,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Company Information' },
|
||||||
|
{ label: 'Shipping Company Agreement' },
|
||||||
|
{ label: 'Bank Letter' },
|
||||||
|
{ label: 'Vehicle, Office & Terminal' },
|
||||||
|
{ label: 'Employees' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.2M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Agreement document with the shipping company', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||||
|
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
|
||||||
|
{ key: 'bookingClerkProfile', label: 'Booking Clerk Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'bookingClerkExp', label: 'Booking Clerk Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'canvasserProfile', label: 'Canvasser Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'canvasserExp', label: 'Canvasser Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'adminProfile', label: 'Administrative Staff Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'adminExp', label: 'Administrative Staff Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'ceoProfile', label: 'CEO/General Manager Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||||
|
{ key: 'ceoExp', label: 'CEO/General Manager Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||||
|
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||||
|
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShippingAgentLicenseApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tradeName, setTradeName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [businessAddress, setBusinessAddress] = useState('');
|
||||||
|
const [officeAddress, setOfficeAddress] = useState('');
|
||||||
|
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||||
|
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 1 — Shipping Company Agreement
|
||||||
|
const [shippingCompanyName, setShippingCompanyName] = useState('');
|
||||||
|
const [agreementRefNumber, setAgreementRefNumber] = useState('');
|
||||||
|
const [agreementStartDate, setAgreementStartDate] = useState('');
|
||||||
|
const [agreementEndDate, setAgreementEndDate] = useState('');
|
||||||
|
|
||||||
|
// Step 2 — Bank Letter
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [accountHolderName, setAccountHolderName] = useState('');
|
||||||
|
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||||
|
|
||||||
|
// Step 3 — Vehicle, Office & Terminal
|
||||||
|
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||||
|
const [plateNumber, setPlateNumber] = useState('');
|
||||||
|
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||||
|
const [terminalName, setTerminalName] = useState('');
|
||||||
|
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 4 — Employees
|
||||||
|
const [bookingClerkName, setBookingClerkName] = useState('');
|
||||||
|
const [canvasserName, setCanvasserName] = useState('');
|
||||||
|
const [adminStaffName, setAdminStaffName] = useState('');
|
||||||
|
const [ceoName, setCeoName] = useState('');
|
||||||
|
|
||||||
|
// Step 5 — Documents
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||||
|
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||||
|
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||||
|
);
|
||||||
|
if (active === 1) return (
|
||||||
|
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
|
||||||
|
!!agreementStartDate && !!agreementEndDate
|
||||||
|
);
|
||||||
|
if (active === 2) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1200000;
|
||||||
|
if (active === 3) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
|
||||||
|
if (active === 4) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!adminStaffName.trim() && !!ceoName.trim();
|
||||||
|
if (active === 5) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-licenses/shipping-agent',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||||
|
businessAddress, officeAddress, applicantType, ownershipType,
|
||||||
|
shippingCompanyName, agreementRefNumber, agreementStartDate, agreementEndDate,
|
||||||
|
bankName, accountHolderName, capitalAmount,
|
||||||
|
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
|
||||||
|
bookingClerkName, canvasserName, adminStaffName, ceoName,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('Shipping Agent License application submitted successfully!');
|
||||||
|
navigate('/shipping-agent-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Shipping Agent License Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Company Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||||
|
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||||
|
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Provide the details of your agreement with the shipping company you represent.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Shipping Company Agreement" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Agreement Start Date" type="date" required value={agreementStartDate} onChange={(e) => setAgreementStartDate(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Agreement End Date" type="date" required value={agreementEndDate} onChange={(e) => setAgreementEndDate(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Bank letter must show a minimum capital of 1,200,000 ETB.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||||
|
<NumberInput
|
||||||
|
label="Capital Amount (ETB)"
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
value={capitalAmount}
|
||||||
|
onChange={setCapitalAmount}
|
||||||
|
error={capitalAmount && Number(capitalAmount) < 1200000 ? 'Must be at least 1,200,000 ETB' : undefined}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Vehicle Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||||
|
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Office Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Terminal Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
|
||||||
|
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Four employee roles are required: Booking Clerk, Canvasser, Administrative Staff, and CEO/General Manager.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Employee Profiles" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 5 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 6 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="Trade Name" value={tradeName} />
|
||||||
|
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||||
|
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||||
|
<ReviewRow label="Business Address" value={businessAddress} />
|
||||||
|
<ReviewRow label="Office Address" value={officeAddress} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Shipping Company Agreement</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
|
||||||
|
<ReviewRow label="Agreement Reference Number" value={agreementRefNumber} />
|
||||||
|
<ReviewRow label="Agreement Start Date" value={agreementStartDate} />
|
||||||
|
<ReviewRow label="Agreement End Date" value={agreementEndDate} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Bank Name" value={bankName} />
|
||||||
|
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Terminal Name" value={terminalName} />
|
||||||
|
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
|
||||||
|
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
|
||||||
|
<ReviewRow label="Canvasser" value={canvasserName} />
|
||||||
|
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||||
|
<ReviewRow label="CEO / General Manager" value={ceoName} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{DOC_SLOTS.map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/shipping-agent-license') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCertificate,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconDownload,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type LicenseStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||||
|
|
||||||
|
interface ShippingAgentApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
status: LicenseStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||||
|
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||||
|
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShippingAgentLicensePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<ShippingAgentApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<ShippingAgentApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-licenses/shipping-agent/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Shipping Agent License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Apply for and manage your Shipping Agent License</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for a Shipping Agent License</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, terminal, and employee information</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Bank letter showing at least 1.2 million ETB" />
|
||||||
|
<RequirementItem label="Shipping company agreement" />
|
||||||
|
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||||
|
<RequirementItem label="Office title deed or office rental agreement" />
|
||||||
|
<RequirementItem label="Terminal agreement or terminal title deed" />
|
||||||
|
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff and CEO/General Manager profiles" />
|
||||||
|
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/shipping-agent-license/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About Shipping Agent Licensing</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||||
|
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Payment Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||||
|
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||||
|
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Certificate Issued' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||||
|
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application.status === 'Certificate Issued' && (
|
||||||
|
<div>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||||
|
</Group>
|
||||||
|
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||||
|
Your Shipping Agent License certificate is ready. Valid until {application.expiryDate}.
|
||||||
|
</Alert>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">Shipping Agent License Certificate</Text>
|
||||||
|
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||||
|
Download Certificate
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { FileButton } from '@mantine/core';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RENEWAL_DOCS: DocSlot[] = [
|
||||||
|
{ key: 'prevCertificate', label: 'Previous Shipping Agent Certificate', description: 'Your current/expiring certificate', required: true },
|
||||||
|
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||||
|
{ key: 'threeMonthClearance', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months', required: true },
|
||||||
|
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||||
|
{ key: 'paymentReceipt', label: 'Renewal Service Payment Receipt (600 ETB)', description: 'Proof of payment for the renewal service fee', required: true },
|
||||||
|
{ key: 'renewalDeclaration', label: 'Applicant Renewal Declaration', description: 'Signed declaration confirming renewal details', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShippingAgentLicenseRenewalPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({ url: '/logistics-licenses/shipping-agent/renew', method: 'POST', body: {} }).unwrap();
|
||||||
|
notify.success('Renewal request submitted successfully!');
|
||||||
|
navigate('/shipping-agent-license');
|
||||||
|
} catch {
|
||||||
|
notify.error('Renewal submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Renew Shipping Agent License</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
A renewal service payment of 600 ETB is required. If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||||
|
<Stack gap="md">
|
||||||
|
{RENEWAL_DOCS.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<IconCheck size={16} />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit Renewal Request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
rem,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCheck,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconReceipt,
|
||||||
|
IconShieldOff,
|
||||||
|
IconShip,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: 'Waiver Type & Applicant' },
|
||||||
|
{ label: 'Import Product' },
|
||||||
|
{ label: 'Port & Shipment' },
|
||||||
|
{ label: 'Documents Upload' },
|
||||||
|
{ label: 'Review & Submit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DocSlot {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
icon: typeof IconId;
|
||||||
|
accept?: string;
|
||||||
|
postWaiverOnly?: boolean;
|
||||||
|
preWaiverOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||||
|
return (
|
||||||
|
<Box mb={32}>
|
||||||
|
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||||
|
{STEPS.map((step, i) => {
|
||||||
|
const isDone = completed.includes(i);
|
||||||
|
const isCurrent = active === i;
|
||||||
|
return (
|
||||||
|
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||||
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: rem(40),
|
||||||
|
height: rem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: isDone
|
||||||
|
? 'var(--mantine-color-blue-8)'
|
||||||
|
: isCurrent
|
||||||
|
? 'var(--mantine-color-blue-7)'
|
||||||
|
: 'var(--mantine-color-gray-1)',
|
||||||
|
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||||
|
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{isDone ? `${step.label} ✓` : step.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i < STEPS.length - 1 && (
|
||||||
|
<Box style={{
|
||||||
|
flex: 1, height: rem(2), minWidth: rem(24),
|
||||||
|
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||||
|
marginBottom: rem(22),
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Divider mt="md" mb="xs" />
|
||||||
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||||
|
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||||
|
const resetRef = useRef<() => void>(null);
|
||||||
|
const SlotIcon = slot.icon;
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" style={{
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||||
|
}}>
|
||||||
|
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||||
|
<Box style={{
|
||||||
|
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||||
|
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{file ? (
|
||||||
|
<Group gap="xs" align="center">
|
||||||
|
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||||
|
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||||
|
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_SLOTS: DocSlot[] = [
|
||||||
|
{ key: 'importCertificate', label: 'Import Certificate', description: 'Import certificate for the shipment', required: true, icon: IconId },
|
||||||
|
{ key: 'invoice', label: 'Invoice for Imported Products', description: 'Commercial invoice for the goods', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'tinCert', label: 'TIN Certificate', description: 'TIN certificate / TIN number evidence', required: true, icon: IconId },
|
||||||
|
{ key: 'declaration', label: 'Applicant Declaration', description: 'Signed applicant declaration', required: true, icon: IconFileDescription },
|
||||||
|
{ key: 'supportingLetter', label: 'Supporting / Request Letter', description: 'Optional supporting letter', required: false, icon: IconFileDescription },
|
||||||
|
{ key: 'billOfLading', label: 'Bill of Lading', description: 'Shipment bill of lading', required: false, icon: IconFileDescription },
|
||||||
|
{ key: 'proformaInvoice', label: 'Proforma / Commercial Invoice', description: 'Required for Pre-Waiver', required: true, icon: IconFileDescription, preWaiverOnly: true },
|
||||||
|
{ key: 'shipmentSchedule', label: 'Shipment Schedule / Booking Evidence', description: 'Optional vessel booking evidence', required: false, icon: IconFileDescription, preWaiverOnly: true },
|
||||||
|
{ key: 'arrivalNotice', label: 'Arrival Notice / Port Arrival Evidence', description: 'Required for Post-Waiver', required: true, icon: IconFileDescription, postWaiverOnly: true },
|
||||||
|
{ key: 'penaltyReceipt', label: 'Penalty Payment Receipt', description: 'Upload after penalty payment is made', required: false, icon: IconReceipt, postWaiverOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WaiverApplicationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [completed, setCompleted] = useState<number[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [waiverType, setWaiverType] = useState<string | null>(null);
|
||||||
|
const [cargoArrivalStatus, setCargoArrivalStatus] = useState<string | null>(null);
|
||||||
|
const [companyName, setCompanyName] = useState('');
|
||||||
|
const [tinNumber, setTinNumber] = useState('');
|
||||||
|
const [importCertNumber, setImportCertNumber] = useState('');
|
||||||
|
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||||
|
const [applicantAddress, setApplicantAddress] = useState('');
|
||||||
|
const [reasonNonEsl, setReasonNonEsl] = useState('');
|
||||||
|
const [bankName, setBankName] = useState('');
|
||||||
|
const [bankBranch, setBankBranch] = useState('');
|
||||||
|
const [letterRecipient, setLetterRecipient] = useState('');
|
||||||
|
const [letterLanguage, setLetterLanguage] = useState<string | null>('English');
|
||||||
|
|
||||||
|
const [productName, setProductName] = useState('');
|
||||||
|
const [productDescription, setProductDescription] = useState('');
|
||||||
|
const [quantity, setQuantity] = useState<string | number>('');
|
||||||
|
const [unitOfMeasurement, setUnitOfMeasurement] = useState('');
|
||||||
|
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||||
|
const [invoiceDate, setInvoiceDate] = useState('');
|
||||||
|
const [invoiceAmount, setInvoiceAmount] = useState<string | number>('');
|
||||||
|
const [countryOfOrigin, setCountryOfOrigin] = useState('');
|
||||||
|
const [supplierName, setSupplierName] = useState('');
|
||||||
|
|
||||||
|
const [portOfLoading, setPortOfLoading] = useState('');
|
||||||
|
const [portOfDischarge, setPortOfDischarge] = useState('');
|
||||||
|
const [fromPortName, setFromPortName] = useState('');
|
||||||
|
const [toPortName, setToPortName] = useState('');
|
||||||
|
const [vesselName, setVesselName] = useState('');
|
||||||
|
const [carrierName, setCarrierName] = useState('');
|
||||||
|
const [billOfLadingNumber, setBillOfLadingNumber] = useState('');
|
||||||
|
const [estimatedArrivalDate, setEstimatedArrivalDate] = useState('');
|
||||||
|
const [actualArrivalDate, setActualArrivalDate] = useState('');
|
||||||
|
|
||||||
|
const isPreWaiver = waiverType === 'Pre-Waiver';
|
||||||
|
const isPostWaiver = waiverType === 'Post-Waiver';
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||||
|
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||||
|
);
|
||||||
|
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||||
|
|
||||||
|
const visibleDocSlots = DOC_SLOTS.filter((s) => {
|
||||||
|
if (s.preWaiverOnly) return isPreWaiver;
|
||||||
|
if (s.postWaiverOnly) return isPostWaiver;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canNext = () => {
|
||||||
|
if (active === 0) return (
|
||||||
|
!!waiverType && !!cargoArrivalStatus &&
|
||||||
|
(!isPreWaiver || cargoArrivalStatus === 'Not Arrived at Port') &&
|
||||||
|
(!isPostWaiver || cargoArrivalStatus === 'Arrived at Port') &&
|
||||||
|
!!companyName.trim() && !!tinNumber.trim() && !!importCertNumber.trim() &&
|
||||||
|
!!applicantAddress.trim() && !!reasonNonEsl.trim() && !!bankName.trim() && !!letterRecipient.trim()
|
||||||
|
);
|
||||||
|
if (active === 1) return (
|
||||||
|
!!productName.trim() && !!productDescription.trim() && !!quantity && !!unitOfMeasurement.trim() &&
|
||||||
|
!!invoiceNumber.trim() && !!invoiceDate && !!invoiceAmount
|
||||||
|
);
|
||||||
|
if (active === 2) return (
|
||||||
|
!!portOfLoading.trim() && !!portOfDischarge.trim() && !!fromPortName.trim() && !!toPortName.trim() &&
|
||||||
|
!!vesselName.trim() && !!carrierName.trim() &&
|
||||||
|
(!isPreWaiver || !!estimatedArrivalDate) &&
|
||||||
|
(!isPostWaiver || !!actualArrivalDate)
|
||||||
|
);
|
||||||
|
if (active === 3) return visibleDocSlots.every((s) => !s.required || !!files[s.key]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||||
|
setActive((c) => c + 1);
|
||||||
|
};
|
||||||
|
const prev = () => setActive((c) => c - 1);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await submitTrigger({
|
||||||
|
url: '/logistics-waivers',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
waiverType, cargoArrivalStatus, companyName, tinNumber, importCertNumber,
|
||||||
|
businessLicenseNumber, applicantAddress, reasonNonEsl, bankName, bankBranch,
|
||||||
|
letterRecipient, letterLanguage,
|
||||||
|
productName, productDescription, quantity, unitOfMeasurement,
|
||||||
|
invoiceNumber, invoiceDate, invoiceAmount, countryOfOrigin, supplierName,
|
||||||
|
portOfLoading, portOfDischarge, fromPortName, toPortName, vesselName, carrierName,
|
||||||
|
billOfLadingNumber, estimatedArrivalDate, actualArrivalDate,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
notify.success('Waiver application submitted successfully!');
|
||||||
|
navigate('/waiver');
|
||||||
|
} catch {
|
||||||
|
notify.error('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/waiver')}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Waiver Application</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator active={active} completed={completed} />
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||||
|
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{active === 0 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||||
|
</Alert>
|
||||||
|
<SectionHead title="Waiver Type" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Select
|
||||||
|
label="Waiver Type"
|
||||||
|
required
|
||||||
|
data={['Pre-Waiver', 'Post-Waiver']}
|
||||||
|
value={waiverType}
|
||||||
|
onChange={(v) => { setWaiverType(v); setCargoArrivalStatus(null); }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Cargo Arrival Status"
|
||||||
|
required
|
||||||
|
data={['Not Arrived at Port', 'Arrived at Port']}
|
||||||
|
value={cargoArrivalStatus}
|
||||||
|
onChange={setCargoArrivalStatus}
|
||||||
|
disabled={!waiverType}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
{waiverType && cargoArrivalStatus && (
|
||||||
|
(isPreWaiver && cargoArrivalStatus !== 'Not Arrived at Port') ||
|
||||||
|
(isPostWaiver && cargoArrivalStatus !== 'Arrived at Port')
|
||||||
|
) && (
|
||||||
|
<Alert color="red" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Pre-Waiver requires cargo not yet arrived at port; Post-Waiver requires cargo already arrived at port.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isPostWaiver && (
|
||||||
|
<Alert color="grape" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Post-Waiver requires a penalty payment and is issued only once per import/cargo case.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHead title="Applicant & Company Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Company / Importer Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Importer Certificate Number" required value={importCertNumber} onChange={(e) => setImportCertNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Business License Number" value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Applicant Address" required value={applicantAddress} onChange={(e) => setApplicantAddress(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SectionHead title="Waiver Request" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<Textarea label="Reason for Using Non-ESL Vessel" required value={reasonNonEsl} onChange={(e) => setReasonNonEsl(e.currentTarget.value)} autosize minRows={2} />
|
||||||
|
<Select label="Requested Letter Language" data={['English', 'Amharic']} value={letterLanguage} onChange={setLetterLanguage} />
|
||||||
|
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Bank Branch" value={bankBranch} onChange={(e) => setBankBranch(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Letter Recipient" required placeholder="e.g. Bank Manager" value={letterRecipient} onChange={(e) => setLetterRecipient(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 1 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Product / Commodity" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Product / Commodity Name" required value={productName} onChange={(e) => setProductName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Product Description" required value={productDescription} onChange={(e) => setProductDescription(e.currentTarget.value)} />
|
||||||
|
<NumberInput label="Quantity" required min={0} value={quantity} onChange={setQuantity} />
|
||||||
|
<TextInput label="Unit of Measurement" required value={unitOfMeasurement} onChange={(e) => setUnitOfMeasurement(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Country of Origin" value={countryOfOrigin} onChange={(e) => setCountryOfOrigin(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Supplier / Exporter Name" value={supplierName} onChange={(e) => setSupplierName(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Invoice Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Invoice Number" required value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput type="date" label="Invoice Date" required value={invoiceDate} onChange={(e) => setInvoiceDate(e.currentTarget.value)} />
|
||||||
|
<NumberInput label="Invoice Amount" required min={0} value={invoiceAmount} onChange={setInvoiceAmount} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SectionHead title="Port Information" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Port of Loading" required value={portOfLoading} onChange={(e) => setPortOfLoading(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Port of Discharge / Destination Port" required value={portOfDischarge} onChange={(e) => setPortOfDischarge(e.currentTarget.value)} />
|
||||||
|
<TextInput label="From Port Name" required value={fromPortName} onChange={(e) => setFromPortName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="To Port Name" required value={toPortName} onChange={(e) => setToPortName(e.currentTarget.value)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<SectionHead title="Vessel & Shipment" />
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<TextInput label="Vessel Name" required value={vesselName} onChange={(e) => setVesselName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Shipping Line / Carrier Name" required value={carrierName} onChange={(e) => setCarrierName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Bill of Lading Number" value={billOfLadingNumber} onChange={(e) => setBillOfLadingNumber(e.currentTarget.value)} />
|
||||||
|
{isPreWaiver && (
|
||||||
|
<TextInput type="date" label="Estimated Arrival Date" required value={estimatedArrivalDate} onChange={(e) => setEstimatedArrivalDate(e.currentTarget.value)} />
|
||||||
|
)}
|
||||||
|
{isPostWaiver && (
|
||||||
|
<TextInput type="date" label="Actual Arrival Date" required value={actualArrivalDate} onChange={(e) => setActualArrivalDate(e.currentTarget.value)} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 3 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
|
||||||
|
{isPostWaiver && ' Post-Waiver requires Bill of Lading and arrival evidence; penalty receipt can be added after payment.'}
|
||||||
|
</Alert>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{visibleDocSlots.map((slot) => (
|
||||||
|
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === 4 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
|
Please review all information before submitting.
|
||||||
|
{isPostWaiver && ' If approved, you will be asked to pay a penalty before the waiver letter is generated.'}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Waiver Type & Applicant</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Waiver Type" value={waiverType ?? ''} />
|
||||||
|
<ReviewRow label="Cargo Arrival Status" value={cargoArrivalStatus ?? ''} />
|
||||||
|
<ReviewRow label="Company Name" value={companyName} />
|
||||||
|
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||||
|
<ReviewRow label="Import Certificate Number" value={importCertNumber} />
|
||||||
|
<ReviewRow label="Bank Name" value={bankName} />
|
||||||
|
<ReviewRow label="Letter Recipient" value={letterRecipient} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Import Product</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="Product Name" value={productName} />
|
||||||
|
<ReviewRow label="Quantity" value={`${quantity} ${unitOfMeasurement}`} />
|
||||||
|
<ReviewRow label="Invoice Number" value={invoiceNumber} />
|
||||||
|
<ReviewRow label="Invoice Amount" value={invoiceAmount ? `${Number(invoiceAmount).toLocaleString()}` : ''} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Port & Shipment</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
<ReviewRow label="From Port" value={fromPortName} />
|
||||||
|
<ReviewRow label="To Port" value={toPortName} />
|
||||||
|
<ReviewRow label="Vessel Name" value={vesselName} />
|
||||||
|
<ReviewRow label="Carrier Name" value={carrierName} />
|
||||||
|
{isPreWaiver && <ReviewRow label="Estimated Arrival Date" value={estimatedArrivalDate} />}
|
||||||
|
{isPostWaiver && <ReviewRow label="Actual Arrival Date" value={actualArrivalDate} />}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{visibleDocSlots.map((slot) => (
|
||||||
|
<Group key={slot.key} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||||
|
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xl">
|
||||||
|
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/waiver') : prev}>
|
||||||
|
{active === 0 ? 'Cancel' : 'Back'}
|
||||||
|
</Button>
|
||||||
|
{active < STEPS.length - 1 ? (
|
||||||
|
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||||
|
) : (
|
||||||
|
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||||
|
Submit Application
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
apps/portal/src/app/features/waiver/pages/WaiverPage.tsx
Normal file
219
apps/portal/src/app/features/waiver/pages/WaiverPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconClockHour4,
|
||||||
|
IconDownload,
|
||||||
|
IconFileCertificate,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconShieldOff,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
|
import { authStorage } from '@ema-platform/auth';
|
||||||
|
|
||||||
|
type WaiverStatus =
|
||||||
|
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||||
|
| 'Resubmit Required' | 'Rejected' | 'Penalty Payment Pending' | 'Payment Confirmed' | 'Letter Generated' | 'Completed';
|
||||||
|
|
||||||
|
interface WaiverApplication {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
waiverType: 'Pre-Waiver' | 'Post-Waiver';
|
||||||
|
status: WaiverStatus;
|
||||||
|
submittedDate: string;
|
||||||
|
approvalDate: string | null;
|
||||||
|
remarks: string;
|
||||||
|
penaltyAmount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RequirementItem({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||||
|
<Text fz="sm">{label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WaiverPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [application, setApplication] = useState<WaiverApplication | null>(null);
|
||||||
|
const [fetchTrigger] = useApiMutation<WaiverApplication>();
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const profileId = authStorage.getProfileId();
|
||||||
|
if (!profileId || fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
|
fetchTrigger({ url: '/logistics-waivers/my', method: 'GET' })
|
||||||
|
.unwrap()
|
||||||
|
.then((data) => setApplication(data))
|
||||||
|
.catch(() => {/* no application yet */});
|
||||||
|
}, [fetchTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShieldOff size={24} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Maritime Logistics Waiver</Title>
|
||||||
|
<Text fz="sm" c="dimmed">Request authorization to use a non-ESL vessel for importing goods</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!application && (
|
||||||
|
<>
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group gap="md" mb="lg" wrap="nowrap">
|
||||||
|
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShieldOff size={28} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">Apply for a Waiver</Text>
|
||||||
|
<Text fz="sm" c="dimmed">Request a Pre-Waiver (cargo not yet arrived) or Post-Waiver (cargo already arrived)</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||||
|
<Stack gap={6} mb="xl">
|
||||||
|
<RequirementItem label="Import certificate" />
|
||||||
|
<RequirementItem label="Invoice for imported products" />
|
||||||
|
<RequirementItem label="TIN certificate / TIN number evidence" />
|
||||||
|
<RequirementItem label="Applicant declaration" />
|
||||||
|
<RequirementItem label="Bill of Lading and arrival evidence (Post-Waiver)" />
|
||||||
|
<RequirementItem label="Penalty payment receipt (Post-Waiver, after approval)" />
|
||||||
|
</Stack>
|
||||||
|
<Button size="md" leftSection={<IconShieldOff size={18} />} onClick={() => navigate('/waiver/apply')}>
|
||||||
|
Start Application
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||||
|
<Text fw={600} fz="sm" c="blue.7">About the Waiver</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
A waiver lets you use a non-ESL vessel to transport imported goods into Ethiopia. Once approved
|
||||||
|
(and, for Post-Waiver, once any penalty payment is confirmed), EMA issues an official waiver letter
|
||||||
|
addressed to your bank for import clearance. <strong>Post-Waiver is only issued once per cargo case.</strong>
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{application && (
|
||||||
|
<>
|
||||||
|
{application.status === 'Resubmit Required' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||||
|
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Rejected' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||||
|
{application.remarks || 'Your application was rejected.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{application.status === 'Penalty Payment Pending' && (
|
||||||
|
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Penalty Payment Required">
|
||||||
|
Your Post-Waiver has been approved. Please pay the penalty of {application.penaltyAmount?.toLocaleString() ?? '—'} ETB
|
||||||
|
to proceed with waiver letter generation.
|
||||||
|
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl">
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShieldOff size={22} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||||
|
<Text fz="xs" c="dimmed">{application.id} · {application.waiverType}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{application.remarks && (
|
||||||
|
<>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||||
|
<Text fz="sm">{application.remarks}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{application.status !== 'Completed' && application.status !== 'Letter Generated' && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconClockHour4 size={16} />
|
||||||
|
<Text fw={600} fz="sm">Application Status</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{[
|
||||||
|
{ label: 'Submitted', done: true },
|
||||||
|
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||||
|
{ label: 'Approved', done: ['Approved', 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed'].includes(application.status) },
|
||||||
|
...(application.waiverType === 'Post-Waiver'
|
||||||
|
? [{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Letter Generated', 'Completed'].includes(application.status) }]
|
||||||
|
: []),
|
||||||
|
{ label: 'Letter Generated', done: (['Letter Generated', 'Completed'] as string[]).includes(application.status) },
|
||||||
|
].map((step) => (
|
||||||
|
<Group key={step.label} gap="xs">
|
||||||
|
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||||
|
<IconCheck size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(application.status === 'Letter Generated' || application.status === 'Completed') && (
|
||||||
|
<div>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<IconFileCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||||
|
<Text fw={700} fz="md">Waiver Letter</Text>
|
||||||
|
</Group>
|
||||||
|
<Alert color="teal" icon={<IconCheck size={17} />} mb="md">
|
||||||
|
Your {application.waiverType} letter has been generated and is ready for bank submission.
|
||||||
|
</Alert>
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconFileCertificate size={20} /></ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} fz="sm">EMA Waiver Letter (Bank Copy)</Text>
|
||||||
|
<Text fz="xs" c="dimmed">Official letter addressed to your bank</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||||
|
Download Waiver Letter
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ export const am: Translations = {
|
|||||||
vesselRegistration: 'የመርከብ ምዝገባ',
|
vesselRegistration: 'የመርከብ ምዝገባ',
|
||||||
vesselRegistrationDashboard: 'የመርከብ ምዝገባ ዳሽቦርድ',
|
vesselRegistrationDashboard: 'የመርከብ ምዝገባ ዳሽቦርድ',
|
||||||
ownershipTransfer: 'የባለቤትነት ዝውውር',
|
ownershipTransfer: 'የባለቤትነት ዝውውር',
|
||||||
|
logisticsDashboard: 'የሎጂስቲክስ ፈቃዶች ዳሽቦርድ',
|
||||||
|
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
|
||||||
|
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
|
||||||
|
combinedLicense: 'የተቀናጀ ፈቃድ',
|
||||||
|
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||||
|
mtoLicense: 'የMTO ፈቃድ',
|
||||||
|
waiver: 'ነፃ ፈቃድ',
|
||||||
dashboard: 'ዳሽቦርድ',
|
dashboard: 'ዳሽቦርድ',
|
||||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||||
myApplication: 'ማመልከቻዬ',
|
myApplication: 'ማመልከቻዬ',
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export const en = {
|
|||||||
vesselRegistration: 'Vessel Registration',
|
vesselRegistration: 'Vessel Registration',
|
||||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||||
ownershipTransfer: 'Ownership Transfer',
|
ownershipTransfer: 'Ownership Transfer',
|
||||||
|
logisticsDashboard: 'Logistics Licenses Dashboard',
|
||||||
|
freightForwarderLicense: 'Freight Forwarder License',
|
||||||
|
shippingAgentLicense: 'Shipping Agent License',
|
||||||
|
combinedLicense: 'Combined License',
|
||||||
|
jointInvestmentLicense: 'Joint Investment License',
|
||||||
|
mtoLicense: 'MTO License',
|
||||||
|
waiver: 'Waiver',
|
||||||
certificates: 'Certificates',
|
certificates: 'Certificates',
|
||||||
endorsements: 'Endorsements',
|
endorsements: 'Endorsements',
|
||||||
documents: 'My Documents',
|
documents: 'My Documents',
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
IconRubberStamp,
|
IconRubberStamp,
|
||||||
IconSend,
|
IconSend,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
IconShieldOff,
|
||||||
IconShip,
|
IconShip,
|
||||||
|
IconStack2,
|
||||||
|
IconTruck,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
|
IconUsers,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -30,6 +34,13 @@ const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
|
|||||||
{ to: '/vessel-registration-dashboard', label: 'Vessel Registration Dashboard', i18nKey: 'nav.vesselRegistrationDashboard', icon: IconGauge },
|
{ to: '/vessel-registration-dashboard', label: 'Vessel Registration Dashboard', i18nKey: 'nav.vesselRegistrationDashboard', icon: IconGauge },
|
||||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
||||||
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange },
|
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange },
|
||||||
|
{ to: '/logistics-dashboard', label: 'Logistics Licenses Dashboard', i18nKey: 'nav.logisticsDashboard', icon: IconGauge },
|
||||||
|
{ to: '/freight-forwarder-license', label: 'Freight Forwarder License', i18nKey: 'nav.freightForwarderLicense', icon: IconTruck },
|
||||||
|
{ to: '/shipping-agent-license', label: 'Shipping Agent License', i18nKey: 'nav.shippingAgentLicense', icon: IconShip },
|
||||||
|
{ to: '/combined-license', label: 'Combined License', i18nKey: 'nav.combinedLicense', icon: IconStack2 },
|
||||||
|
{ to: '/joint-investment-license', label: 'Joint Investment License', i18nKey: 'nav.jointInvestmentLicense', icon: IconUsers },
|
||||||
|
{ to: '/mto-license', label: 'MTO License', i18nKey: 'nav.mtoLicense', icon: IconTruck },
|
||||||
|
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
||||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
||||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||||
@@ -43,6 +54,13 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
|
|||||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
||||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
||||||
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||||
|
'/logistics-dashboard': { i18nKey: 'nav.logisticsDashboard' },
|
||||||
|
'/freight-forwarder-license': { i18nKey: 'nav.freightForwarderLicense' },
|
||||||
|
'/shipping-agent-license': { i18nKey: 'nav.shippingAgentLicense' },
|
||||||
|
'/combined-license': { i18nKey: 'nav.combinedLicense' },
|
||||||
|
'/joint-investment-license': { i18nKey: 'nav.jointInvestmentLicense' },
|
||||||
|
'/mto-license': { i18nKey: 'nav.mtoLicense' },
|
||||||
|
'/waiver': { i18nKey: 'nav.waiver' },
|
||||||
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
|
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
|
||||||
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
'/seaman-book': { i18nKey: 'nav.myApplication' },
|
||||||
'/certificates': { i18nKey: 'nav.certificates' },
|
'/certificates': { i18nKey: 'nav.certificates' },
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
|
|||||||
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
|
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
|
||||||
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
|
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
|
||||||
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
|
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
|
||||||
|
import { LogisticsDashboardPage } from './features/logistics-dashboard/pages/LogisticsDashboardPage';
|
||||||
|
import { FreightForwarderLicensePage } from './features/freight-forwarder-license/pages/FreightForwarderLicensePage';
|
||||||
|
import { FreightForwarderLicenseApplicationPage } from './features/freight-forwarder-license/pages/FreightForwarderLicenseApplicationPage';
|
||||||
|
import { FreightForwarderLicenseRenewalPage } from './features/freight-forwarder-license/pages/FreightForwarderLicenseRenewalPage';
|
||||||
|
import { ShippingAgentLicensePage } from './features/shipping-agent-license/pages/ShippingAgentLicensePage';
|
||||||
|
import { ShippingAgentLicenseApplicationPage } from './features/shipping-agent-license/pages/ShippingAgentLicenseApplicationPage';
|
||||||
|
import { ShippingAgentLicenseRenewalPage } from './features/shipping-agent-license/pages/ShippingAgentLicenseRenewalPage';
|
||||||
|
import { CombinedLicensePage } from './features/combined-license/pages/CombinedLicensePage';
|
||||||
|
import { CombinedLicenseApplicationPage } from './features/combined-license/pages/CombinedLicenseApplicationPage';
|
||||||
|
import { CombinedLicenseRenewalPage } from './features/combined-license/pages/CombinedLicenseRenewalPage';
|
||||||
|
import { JointInvestmentLicensePage } from './features/joint-investment-license/pages/JointInvestmentLicensePage';
|
||||||
|
import { JointInvestmentLicenseApplicationPage } from './features/joint-investment-license/pages/JointInvestmentLicenseApplicationPage';
|
||||||
|
import { JointInvestmentLicenseRenewalPage } from './features/joint-investment-license/pages/JointInvestmentLicenseRenewalPage';
|
||||||
|
import { MtoLicensePage } from './features/mto-license/pages/MtoLicensePage';
|
||||||
|
import { MtoLicenseApplicationPage } from './features/mto-license/pages/MtoLicenseApplicationPage';
|
||||||
|
import { MtoLicenseRenewalPage } from './features/mto-license/pages/MtoLicenseRenewalPage';
|
||||||
|
import { WaiverPage } from './features/waiver/pages/WaiverPage';
|
||||||
|
import { WaiverApplicationPage } from './features/waiver/pages/WaiverApplicationPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
@@ -95,6 +113,24 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
||||||
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
|
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
|
||||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
||||||
|
{ path: '/logistics-dashboard', element: <LogisticsDashboardPage /> },
|
||||||
|
{ path: '/freight-forwarder-license', element: <FreightForwarderLicensePage /> },
|
||||||
|
{ path: '/freight-forwarder-license/apply', element: <FreightForwarderLicenseApplicationPage /> },
|
||||||
|
{ path: '/freight-forwarder-license/:id/renew', element: <FreightForwarderLicenseRenewalPage /> },
|
||||||
|
{ path: '/shipping-agent-license', element: <ShippingAgentLicensePage /> },
|
||||||
|
{ path: '/shipping-agent-license/apply', element: <ShippingAgentLicenseApplicationPage /> },
|
||||||
|
{ path: '/shipping-agent-license/:id/renew', element: <ShippingAgentLicenseRenewalPage /> },
|
||||||
|
{ path: '/combined-license', element: <CombinedLicensePage /> },
|
||||||
|
{ path: '/combined-license/apply', element: <CombinedLicenseApplicationPage /> },
|
||||||
|
{ path: '/combined-license/:id/renew', element: <CombinedLicenseRenewalPage /> },
|
||||||
|
{ path: '/joint-investment-license', element: <JointInvestmentLicensePage /> },
|
||||||
|
{ path: '/joint-investment-license/apply', element: <JointInvestmentLicenseApplicationPage /> },
|
||||||
|
{ path: '/joint-investment-license/:id/renew', element: <JointInvestmentLicenseRenewalPage /> },
|
||||||
|
{ path: '/mto-license', element: <MtoLicensePage /> },
|
||||||
|
{ path: '/mto-license/apply', element: <MtoLicenseApplicationPage /> },
|
||||||
|
{ path: '/mto-license/:id/renew', element: <MtoLicenseRenewalPage /> },
|
||||||
|
{ path: '/waiver', element: <WaiverPage /> },
|
||||||
|
{ path: '/waiver/apply', element: <WaiverApplicationPage /> },
|
||||||
|
|
||||||
// General
|
// General
|
||||||
{ path: '/profile', element: <ProfilePage /> },
|
{ path: '/profile', element: <ProfilePage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user