Merge pull request #4 from Tria-plc/feature/logestic

Feature/logestic - Fitsum changes merging
This commit is contained in:
mulish77
2026-07-28 14:57:44 +03:00
committed by GitHub
40 changed files with 9901 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,183 @@
import { useNavigate } from 'react-router-dom';
import {
Anchor,
Badge,
Button,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
IconClockHour4,
IconShieldOff,
IconStack2,
IconTruck,
IconUsers,
IconShip,
} from '@tabler/icons-react';
import { MOCK_FF_APPLICATIONS } from '../../freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
import { MOCK_SA_APPLICATIONS } from '../../shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
import { MOCK_COMBINED_APPLICATIONS } from '../../combined-license/pages/CombinedLicenseQueuePage';
import { MOCK_JV_APPLICATIONS } from '../../joint-investment-license/pages/JointInvestmentLicenseQueuePage';
import { MOCK_MTO_APPLICATIONS } from '../../mto-license/pages/MtoLicenseQueuePage';
import { MOCK_WAIVER_APPLICATIONS } from '../../waiver/pages/WaiverQueuePage';
const STATUS_COLOR: Record<string, string> = {
Submitted: 'gray',
'Under Review': 'blue',
'Under Evaluation': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Resubmit Required': 'orange',
'Certificate Issued': 'green',
Completed: 'green',
};
const LICENSE_LINES = [
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', icon: IconTruck, apps: MOCK_FF_APPLICATIONS },
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', icon: IconShip, apps: MOCK_SA_APPLICATIONS },
{ key: 'combined', label: 'Combined License', route: '/combined-license', icon: IconStack2, apps: MOCK_COMBINED_APPLICATIONS },
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', icon: IconUsers, apps: MOCK_JV_APPLICATIONS },
{ key: 'mto', label: 'MTO License', route: '/mto-license', icon: IconTruck, apps: MOCK_MTO_APPLICATIONS },
{ key: 'waiver', label: 'Waiver', route: '/waiver', icon: IconShieldOff, apps: MOCK_WAIVER_APPLICATIONS },
] as const;
const IN_PROGRESS_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Resubmit Required']);
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: typeof IconTruck;
color: string;
}) {
return (
<Paper p="lg" radius="lg" withBorder>
<ThemeIcon size={46} radius="md" variant="light" color={color}>
<Icon size={22} />
</ThemeIcon>
<Text fz={30} fw={800} mt="md" lh={1.1}>
{value}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{label}
</Text>
</Paper>
);
}
export function LogisticsHeadDashboardPage() {
const navigate = useNavigate();
const allApps = LICENSE_LINES.flatMap((line) =>
line.apps.map((a) => ({ ...a, _line: line.label, _route: line.route }))
);
const stats = {
total: allApps.length,
inProgress: allApps.filter((a) => IN_PROGRESS_STATUSES.has(a.status)).length,
issued: allApps.filter((a) => ISSUED_STATUSES.has(a.status)).length,
rejected: allApps.filter((a) => a.status === 'Rejected').length,
};
const recent = [...allApps]
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
.slice(0, 6);
return (
<Stack gap="xl">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
<IconStack2 size={24} />
</ThemeIcon>
<Stack gap={0}>
<Title order={2}>Logistics Licensing Department</Title>
<Text c="dimmed" size="sm">
Overview across freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications
</Text>
</Stack>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<StatCard label="Total Applications" value={stats.total} icon={IconStack2} color="indigo" />
<StatCard label="In Progress" value={stats.inProgress} icon={IconClockHour4} color="yellow" />
<StatCard label="Certificates Issued" value={stats.issued} icon={IconCircleCheck} color="teal" />
<StatCard label="Rejected" value={stats.rejected} icon={IconShieldOff} color="red" />
</SimpleGrid>
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Text fw={700} fz="lg" mb="md">Recent Applications</Text>
<Stack gap={0}>
{recent.map((a, i) => (
<Group
key={a.id}
justify="space-between"
wrap="nowrap"
py="sm"
style={{
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
cursor: 'pointer',
}}
onClick={() => navigate(`${a._route}/${a.id}`)}
>
<Stack gap={0}>
<Text size="sm" fw={600}>{a.companyName}</Text>
<Text size="xs" c="dimmed">{a._line} {a.id}</Text>
</Stack>
<Badge variant="light" color={STATUS_COLOR[a.status] ?? 'gray'} radius="sm">
{a.status}
</Badge>
</Group>
))}
</Stack>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper p="lg" radius="lg" withBorder h="100%">
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">License Queues</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
All
<IconArrowRight size={14} />
</Group>
</Anchor>
</Group>
<Stack gap="sm">
{LICENSE_LINES.map((line) => (
<Button
key={line.key}
fullWidth
variant="light"
leftSection={<line.icon size={16} />}
justify="space-between"
rightSection={<Badge size="sm" variant="filled" color="indigo">{line.apps.length}</Badge>}
onClick={() => navigate(line.route)}
>
{line.label}
</Button>
))}
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,452 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCheck,
IconCircleCheck,
IconEye,
IconSearch,
IconShieldOff,
IconX,
IconAlertCircle,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
export type WaiverType = 'Pre-Waiver' | 'Post-Waiver';
export type WaiverStatus =
| 'Submitted'
| 'Under Review'
| 'Under Evaluation'
| 'Approved'
| 'Resubmit Required'
| 'Rejected'
| 'Penalty Payment Pending'
| 'Payment Confirmed'
| 'Letter Generated'
| 'Completed';
export interface WaiverApplication {
id: string;
companyName: string;
tinNumber: string;
importCertNumber: string;
invoiceNumber: string;
billOfLadingNumber: string;
vesselName: string;
portOfLoading: string;
portOfDischarge: string;
waiverType: WaiverType;
status: WaiverStatus;
submittedDate: string;
approvalDate: string | null;
remarks: string;
docsComplete: boolean;
penaltyAmount: number | null;
penaltyPaid: boolean;
}
export const MOCK_WAIVER_APPLICATIONS: WaiverApplication[] = [
{
id: 'WVR-2024-001',
companyName: 'Blue Nile Import & Trading PLC',
tinNumber: 'TIN-0098231',
importCertNumber: 'IC-772341',
invoiceNumber: 'INV-55231',
billOfLadingNumber: 'BL-90211',
vesselName: 'MV Horizon Star',
portOfLoading: 'Jebel Ali',
portOfDischarge: 'Djibouti',
waiverType: 'Pre-Waiver',
status: 'Under Evaluation',
submittedDate: '2024-03-14',
approvalDate: null,
remarks: 'Reviewing shipment schedule evidence.',
docsComplete: true,
penaltyAmount: null,
penaltyPaid: false,
},
{
id: 'WVR-2024-002',
companyName: 'Red Sea Gateway Logistics Ltd',
tinNumber: 'TIN-0071122',
importCertNumber: 'IC-813457',
invoiceNumber: 'INV-61120',
billOfLadingNumber: 'BL-77102',
vesselName: 'MV Amber Wave',
portOfLoading: 'Salalah',
portOfDischarge: 'Djibouti',
waiverType: 'Post-Waiver',
status: 'Penalty Payment Pending',
submittedDate: '2024-04-05',
approvalDate: '2024-04-10',
remarks: 'Approved. Awaiting penalty payment.',
docsComplete: true,
penaltyAmount: 15000,
penaltyPaid: false,
},
{
id: 'WVR-2023-017',
companyName: 'Tana Maritime & Trading PLC',
tinNumber: 'TIN-0045690',
importCertNumber: 'IC-704128',
invoiceNumber: 'INV-40213',
billOfLadingNumber: 'BL-33012',
vesselName: 'MV Nile Pioneer',
portOfLoading: 'Port Sudan',
portOfDischarge: 'Djibouti',
waiverType: 'Post-Waiver',
status: 'Completed',
submittedDate: '2023-10-12',
approvalDate: '2023-11-08',
remarks: 'Penalty paid. Letter generated and downloaded.',
docsComplete: true,
penaltyAmount: 12000,
penaltyPaid: true,
},
];
export const STATUS_COLOR: Record<string, string> = {
Draft: 'gray',
Submitted: 'blue',
'Under Review': 'yellow',
'Under Evaluation': 'yellow',
Approved: 'teal',
'Resubmit Required': 'orange',
Rejected: 'red',
'Penalty Payment Pending': 'grape',
'Payment Confirmed': 'indigo',
'Letter Generated': 'green',
Completed: 'green',
};
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
function ApplicationDrawer({
app,
opened,
onClose,
onAction,
onFullReview,
}: {
app: WaiverApplication | null;
opened: boolean;
onClose: () => void;
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
onFullReview: (id: string) => void;
}) {
const [remarks, setRemarks] = useState('');
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
if (!app) return null;
const isTerminal = app.status === 'Rejected' || app.status === 'Completed';
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
onAction(app.id, action, remarks);
setRemarks('');
setConfirmModal(null);
onClose();
};
return (
<>
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
<Stack gap="md">
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
Open Full Review Page
</Button>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Applicant Information</Text>
<SimpleGrid cols={2} spacing="xs">
{[
{ label: 'Company Name', value: app.companyName },
{ label: 'TIN Number', value: app.tinNumber },
{ label: 'Import Certificate No.', value: app.importCertNumber },
{ label: 'Waiver Type', value: app.waiverType },
{ label: 'Vessel Name', value: app.vesselName },
{ label: 'Submitted', value: app.submittedDate },
].map((r) => (
<div key={r.label}>
<Text fz="xs" c="dimmed">{r.label}</Text>
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
</div>
))}
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
<Stack gap={6}>
{[
{ label: 'Import Certificate', ok: app.docsComplete },
{ label: 'Invoice for Imported Products', ok: app.docsComplete },
{ label: 'TIN Certificate', ok: app.docsComplete },
{ label: 'Applicant Declaration', ok: app.docsComplete },
...(app.waiverType === 'Post-Waiver'
? [{ label: 'Bill of Lading & Arrival Notice', ok: app.docsComplete }]
: []),
].map((item) => (
<Group key={item.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
<Text fz="sm">{item.label}</Text>
</Group>
))}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} fz="sm">Current Status</Text>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
</Group>
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
</Paper>
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
<Textarea
placeholder="Add notes or instructions..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={3}
mb="sm"
/>
<Group>
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
disabled={!app.docsComplete}
onClick={() => setConfirmModal('approve')}>
Approve
</Button>
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
onClick={() => setConfirmModal('resubmit')}>
Request Resubmission
</Button>
<Button size="xs" color="red" leftSection={<IconX size={14} />}
onClick={() => setConfirmModal('reject')}>
Reject
</Button>
</Group>
</Paper>
)}
</Stack>
</Drawer>
<Modal
opened={!!confirmModal}
onClose={() => setConfirmModal(null)}
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
size="sm"
>
<Text fz="sm" mb="md">
{confirmModal === 'approve'
? `Approve application ${app.id} for "${app.companyName}"?`
: confirmModal === 'reject'
? `Reject application ${app.id}? This cannot be undone.`
: `Request resubmission for application ${app.id}? Officer comment is required.`}
</Text>
<Group justify="flex-end">
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
<Button
size="sm"
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
disabled={confirmModal === 'resubmit' && !remarks.trim()}
onClick={() => confirmModal && submit(confirmModal)}
>
Confirm
</Button>
</Group>
</Modal>
</>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function WaiverQueuePage() {
const navigate = useNavigate();
const [apps, setApps] = useState<WaiverApplication[]>(MOCK_WAIVER_APPLICATIONS);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<WaiverApplication | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchTrigger] = useApiMutation<WaiverApplication[]>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchTrigger({ url: '/logistics-waivers?take=100', method: 'GET' })
.unwrap()
.then((data) => setApps(data))
.catch(() => {/* keep mock */});
}, [fetchTrigger]);
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
setApps((prev) => prev.map((a) => {
if (a.id !== id) return a;
const newStatus: WaiverStatus =
action === 'approve'
? (a.waiverType === 'Post-Waiver' ? 'Penalty Payment Pending' : 'Letter Generated')
: action === 'reject' ? 'Rejected' : 'Resubmit Required';
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
}));
notify.success(
action === 'approve' ? 'Application approved.' :
action === 'reject' ? 'Application rejected.' :
'Resubmission request sent.'
);
};
const filtered = apps.filter((a) => {
const q = search.toLowerCase();
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
const matchStatus = !statusFilter || a.status === statusFilter;
const matchType = !typeFilter || a.waiverType === typeFilter;
return matchSearch && matchStatus && matchType;
});
const stats = {
total: apps.length,
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
letterGenerated: apps.filter((a) => a.status === 'Letter Generated' || a.status === 'Completed').length,
rejected: apps.filter((a) => a.status === 'Rejected').length,
};
const rows = filtered.map((app) => (
<Table.Tr key={app.id}>
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
<Table.Td><Badge variant="outline" size="sm" color={app.waiverType === 'Post-Waiver' ? 'grape' : 'blue'}>{app.waiverType}</Badge></Table.Td>
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => navigate(`/waiver/${app.id}`)}>
Review
</Button>
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
<IconEye size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="md">
<div>
<Title order={3}>Maritime Logistics Waiver Queue</Title>
<Text fz="sm" c="dimmed">Review and process Pre-Waiver and Post-Waiver applications</Text>
</div>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
{[
{ label: 'Total Applications', value: stats.total, color: 'blue' },
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
{ label: 'Letters Generated', value: stats.letterGenerated, color: 'teal' },
{ label: 'Rejected', value: stats.rejected, color: 'red' },
].map((s) => (
<Card key={s.label} withBorder radius="md" p="md">
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
</Card>
))}
</SimpleGrid>
<Group gap="sm">
<TextInput
placeholder="Search by company name, ID, or TIN..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All types"
clearable
data={['Pre-Waiver', 'Post-Waiver']}
value={typeFilter}
onChange={setTypeFilter}
w={160}
/>
<Select
placeholder="All statuses"
clearable
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed']}
value={statusFilter}
onChange={setStatusFilter}
w={220}
/>
</Group>
<Paper withBorder radius="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>App ID</Table.Th>
<Table.Th>Company Name</Table.Th>
<Table.Th>TIN Number</Table.Th>
<Table.Th>Waiver Type</Table.Th>
<Table.Th>Submitted</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? rows : (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Paper>
<ApplicationDrawer
app={selectedApp}
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
onAction={handleAction}
onFullReview={(id) => navigate(`/waiver/${id}`)}
/>
</Stack>
);
}
export const WAIVER_ICON = IconShieldOff;

View File

@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconAlertTriangle,
IconCircleCheck,
IconDownload,
IconEdit,
IconEye,
IconFileCertificate,
IconFileDescription,
IconId,
IconReceipt,
IconShieldOff,
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { MOCK_WAIVER_APPLICATIONS, STATUS_COLOR } from './WaiverQueuePage';
import type { WaiverApplication, WaiverStatus } from './WaiverQueuePage';
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
<Text fz="sm" mt={2}>{value || '—'}</Text>
</div>
);
}
const DOCS_COMMON = [
{ key: 'importCertificate', label: 'Import Certificate', fileName: 'import_certificate.pdf', icon: IconId, required: true },
{ key: 'invoice', label: 'Invoice for Imported Products', fileName: 'invoice.pdf', icon: IconFileDescription, required: true },
{ key: 'tinCert', label: 'TIN Certificate', fileName: 'tin_certificate.pdf', icon: IconId, required: true },
{ key: 'declaration', label: 'Applicant Declaration', fileName: 'declaration.pdf', icon: IconFileDescription, required: true },
];
const DOCS_POST_WAIVER = [
{ key: 'billOfLading', label: 'Bill of Lading', fileName: 'bill_of_lading.pdf', icon: IconFileDescription, required: true },
{ key: 'arrivalNotice', label: 'Arrival Notice / Port Arrival Evidence', fileName: 'arrival_notice.pdf', icon: IconFileDescription, required: true },
{ key: 'penaltyReceipt', label: 'Penalty Payment Receipt', fileName: 'penalty_receipt.pdf', icon: IconReceipt, required: false },
];
// Duplicate Post-Waiver check fields per BR-WVR-006/007 — same import/cargo case identifiers.
function findDuplicatePostWaiver(app: WaiverApplication, all: WaiverApplication[]): WaiverApplication | null {
if (app.waiverType !== 'Post-Waiver') return null;
return all.find((other) =>
other.id !== app.id &&
other.waiverType === 'Post-Waiver' &&
(other.status === 'Approved' || other.status === 'Letter Generated' || other.status === 'Completed') &&
other.tinNumber === app.tinNumber &&
other.importCertNumber === app.importCertNumber &&
other.invoiceNumber === app.invoiceNumber &&
other.billOfLadingNumber === app.billOfLadingNumber &&
other.vesselName === app.vesselName &&
other.portOfLoading === app.portOfLoading &&
other.portOfDischarge === app.portOfDischarge
) ?? null;
}
export function WaiverReviewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [fetchTrigger] = useApiMutation<WaiverApplication>();
const fetched = useRef(false);
const [app, setApp] = useState<WaiverApplication | null>(null);
const [status, setStatus] = useState<WaiverStatus>('Submitted');
const [actionLoading, setActionLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (!id || fetched.current) return;
fetched.current = true;
fetchTrigger({ url: `/logistics-waivers/${id}`, method: 'GET' })
.unwrap()
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
.catch(() => {
const mock = MOCK_WAIVER_APPLICATIONS.find((a) => a.id === id) ?? null;
setApp(mock);
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
});
}, [id, fetchTrigger]);
const isTerminal = status === 'Rejected' || status === 'Completed';
const isPostWaiver = app?.waiverType === 'Post-Waiver';
const duplicate = app ? findDuplicatePostWaiver(app, MOCK_WAIVER_APPLICATIONS) : null;
// Statuses assignable via the modal, gated by BR-WVR-014/015: no letter generation
// before approval, and no Post-Waiver letter before penalty payment confirmation.
const availableStatuses = (() => {
const base = ['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected'];
if (!isPostWaiver) return [...base, 'Letter Generated', 'Completed'];
return [...base, 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed'];
})();
const canSelectLetterGenerated = (s: string) => {
if (s !== 'Letter Generated') return true;
if (!isPostWaiver) return status === 'Approved' || status === 'Letter Generated';
return status === 'Payment Confirmed' || status === 'Letter Generated';
};
const handleAction = async () => {
if (!selectedStatus || !app) return;
setActionLoading(true);
await new Promise((r) => setTimeout(r, 1000));
const newStatus = selectedStatus as WaiverStatus;
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
setApp((prev) => prev ? { ...prev, status: newStatus, approvalDate, remarks } : prev);
setStatus(newStatus);
setActionLoading(false);
setModalOpen(false);
notify.success(`Application status updated to ${newStatus}.`);
};
if (!app) {
return (
<Stack gap="md" p="xl">
<Text c="dimmed">Application not found.</Text>
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/waiver')}>
Back to Queue
</Button>
</Stack>
);
}
const docs = [...DOCS_COMMON, ...(isPostWaiver ? DOCS_POST_WAIVER : [])];
return (
<Stack gap="md">
<Group justify="space-between">
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/waiver')}>
Back to Queue
</Button>
<Group gap="xs">
<Badge variant="outline" size="lg" color={isPostWaiver ? 'grape' : 'blue'}>{app.waiverType}</Badge>
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
</Group>
</Group>
<Group gap="sm">
<ThemeIcon size={44} radius="md" color="blue" variant="light">
<IconShieldOff size={24} />
</ThemeIcon>
<div>
<Title order={3}>{app.companyName}</Title>
<Text fz="sm" c="dimmed">{app.id} · Maritime Logistics Waiver</Text>
</div>
</Group>
{duplicate && status !== 'Rejected' && (
<Alert icon={<IconAlertTriangle size={17} />} color="red" title="Duplicate Post-Waiver Detected">
An already {duplicate.status === 'Letter Generated' || duplicate.status === 'Completed' ? 'letter-generated' : 'approved'} Post-Waiver
application ({duplicate.id}) exists for the same TIN, import certificate, invoice, bill of lading, vessel, and port combination.
Post-Waiver is issued only one time per import/cargo case do not approve this application.
</Alert>
)}
{(status === 'Letter Generated' || status === 'Completed') && (
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Waiver Letter Generated">
Letter generated on {app.approvalDate}.
</Alert>
)}
{status === 'Rejected' && (
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
This application has been rejected. No further changes can be made.
</Alert>
)}
{isPostWaiver && status === 'Penalty Payment Pending' && (
<Alert icon={<IconAlertTriangle size={17} />} color="grape" title="Penalty Payment Pending">
Applicant must pay the penalty ({app.penaltyAmount?.toLocaleString() ?? '—'} ETB) before the waiver letter can be generated.
</Alert>
)}
{!isTerminal && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fw={600} fz="sm">Take Action</Text>
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
Update Status
</Button>
</Group>
</Paper>
)}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Applicant Information</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Company Name" value={app.companyName} />
<InfoRow label="TIN Number" value={app.tinNumber} />
<InfoRow label="Import Certificate No." value={app.importCertNumber} />
<InfoRow label="Waiver Type" value={app.waiverType} />
</SimpleGrid>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Shipment & Vessel</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Invoice Number" value={app.invoiceNumber} />
<InfoRow label="Bill of Lading No." value={app.billOfLadingNumber} />
<InfoRow label="Vessel Name" value={app.vesselName} />
<InfoRow label="Port of Loading" value={app.portOfLoading} />
<InfoRow label="Port of Discharge" value={app.portOfDischarge} />
</SimpleGrid>
</Paper>
{isPostWaiver && (
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Penalty Payment</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<InfoRow label="Penalty Amount" value={app.penaltyAmount ? `${app.penaltyAmount.toLocaleString()} ETB` : 'Not yet assessed'} />
<InfoRow label="Payment Status" value={app.penaltyPaid ? 'Paid' : 'Not Paid'} />
</SimpleGrid>
</Paper>
)}
</Stack>
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
<Stack gap="xs">
{docs.map((doc) => {
const DocIcon = doc.icon;
const ok = app.docsComplete;
return (
<Card key={doc.key} withBorder radius="sm" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
<DocIcon size={18} />
</ThemeIcon>
<div>
<Text fw={600} fz="xs">
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
</Text>
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
</div>
</Group>
{ok ? (
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
</Group>
) : (
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
)}
</Group>
</Card>
);
})}
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
</Paper>
<Paper withBorder radius="md" p="md">
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
<Stack gap={6}>
<InfoRow label="Submitted Date" value={app.submittedDate} />
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
</Stack>
</Paper>
</Stack>
</SimpleGrid>
{(status === 'Letter Generated' || status === 'Completed') && (
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
<Group gap="sm" mb="md">
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
<IconFileCertificate size={20} />
</ThemeIcon>
<div>
<Text fw={700} fz="md" c="teal.7">EMA Waiver Letter {app.waiverType}</Text>
<Text fz="xs" c="dimmed">Generated on {app.approvalDate}</Text>
</div>
</Group>
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
<Group justify="space-between" wrap="nowrap" mb="xs">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconFileCertificate size={16} /></ThemeIcon>
<div>
<Text fw={700} fz="sm">Official EMA Waiver Letter (Bank Copy)</Text>
<Text fz="xs" c="dimmed">Addressed to bank for import clearance</Text>
</div>
</Group>
</Group>
<Group gap="xs" mt="xs">
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
</Group>
</Card>
</Paper>
)}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
<Stack gap="md">
{duplicate && (
<Alert icon={<IconAlertTriangle size={16} />} color="red">
Duplicate Post-Waiver case ({duplicate.id}) already exists approval is not recommended.
</Alert>
)}
<Select
label="New Status"
placeholder="Select status"
data={availableStatuses.map((s) => ({ value: s, label: s, disabled: !canSelectLetterGenerated(s) }))}
value={selectedStatus}
onChange={setSelectedStatus}
/>
{selectedStatus === 'Letter Generated' && !canSelectLetterGenerated(selectedStatus) && (
<Alert color="orange" fz="xs">
{isPostWaiver
? 'Letter can only be generated after penalty payment is confirmed.'
: 'Letter can only be generated after approval.'}
</Alert>
)}
<Textarea
label="Remarks"
placeholder="Add notes for the applicant..."
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
rows={4}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
color={selectedStatus === 'Approved' || selectedStatus === 'Letter Generated' || selectedStatus === 'Completed' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
loading={actionLoading}
disabled={!selectedStatus || !canSelectLetterGenerated(selectedStatus)}
onClick={handleAction}
>
Confirm Update
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -20,6 +20,13 @@ export const am: Translations = {
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
vesselRegistrationReport: 'የመርከብ ምዝገባ ሪፖርት',
ownershipTransferQueue: 'የባለቤትነት ዝውውር ወረፋ',
logisticsHeadDashboard: 'የሎጂስቲክስ ኃላፊ ዳሽቦርድ',
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
combinedLicense: 'የተቀናጀ ፈቃድ',
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
mtoLicense: 'የMTO ፈቃድ',
waiver: 'ነፃ ፈቃድ',
menu: 'ምናሌ',
dashboard: 'ዳሽቦርድ',
userManagement: 'የተጠቃሚ አስተዳደር',

View File

@@ -22,6 +22,13 @@ export const en = {
vesselFormBuilder: 'Vessel Form Builder',
vesselRegistrationReport: 'Vessel Registration Report',
ownershipTransferQueue: 'Ownership Transfer Queue',
logisticsHeadDashboard: 'Logistics Head Dashboard',
freightForwarderLicense: 'Freight Forwarder License',
shippingAgentLicense: 'Shipping Agent License',
combinedLicense: 'Combined License',
jointInvestmentLicense: 'Joint Investment License',
mtoLicense: 'MTO License',
waiver: 'Waiver',
cocQueue: 'CoC / CoP Queue',
endorsementQueue: 'Endorsement Queue',
seafarerRegistry: 'Seafarer Registry',

View File

@@ -25,6 +25,10 @@ import {
IconAnchor,
IconFilePlus,
IconGauge,
IconShieldOff,
IconShip,
IconStack2,
IconTruck,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -39,6 +43,13 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar },
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription },
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
{ to: '/freight-forwarder-license', label: 'nav.freightForwarderLicense', icon: IconTruck },
{ to: '/shipping-agent-license', label: 'nav.shippingAgentLicense', icon: IconShip },
{ to: '/combined-license', label: 'nav.combinedLicense', icon: IconStack2 },
{ to: '/joint-investment-license', label: 'nav.jointInvestmentLicense', icon: IconUsers },
{ to: '/mto-license', label: 'nav.mtoLicense', icon: IconTruck },
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff },
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },

View File

@@ -37,6 +37,19 @@ import { VesselRegistrationFormBuilderPage } from '../features/vessel-registrati
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
import { FreightForwarderLicenseQueuePage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
import { FreightForwarderLicenseReviewPage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseReviewPage';
import { ShippingAgentLicenseQueuePage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
import { ShippingAgentLicenseReviewPage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseReviewPage';
import { CombinedLicenseQueuePage } from '../features/combined-license/pages/CombinedLicenseQueuePage';
import { CombinedLicenseReviewPage } from '../features/combined-license/pages/CombinedLicenseReviewPage';
import { JointInvestmentLicenseQueuePage } from '../features/joint-investment-license/pages/JointInvestmentLicenseQueuePage';
import { JointInvestmentLicenseReviewPage } from '../features/joint-investment-license/pages/JointInvestmentLicenseReviewPage';
import { MtoLicenseQueuePage } from '../features/mto-license/pages/MtoLicenseQueuePage';
import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseReviewPage';
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
const router = createBrowserRouter([
{
@@ -59,6 +72,7 @@ const router = createBrowserRouter([
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: <VesselRegistrationHeadDashboardPage /> },
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'configuration', element: <ConfigurationPage /> },
{ path: 'locations', element: <LocationPage /> },
@@ -82,6 +96,18 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
{ path: 'freight-forwarder-license', element: <FreightForwarderLicenseQueuePage /> },
{ path: 'freight-forwarder-license/:id', element: <FreightForwarderLicenseReviewPage /> },
{ path: 'shipping-agent-license', element: <ShippingAgentLicenseQueuePage /> },
{ path: 'shipping-agent-license/:id', element: <ShippingAgentLicenseReviewPage /> },
{ path: 'combined-license', element: <CombinedLicenseQueuePage /> },
{ path: 'combined-license/:id', element: <CombinedLicenseReviewPage /> },
{ path: 'joint-investment-license', element: <JointInvestmentLicenseQueuePage /> },
{ path: 'joint-investment-license/:id', element: <JointInvestmentLicenseReviewPage /> },
{ path: 'mto-license', element: <MtoLicenseQueuePage /> },
{ path: 'mto-license/:id', element: <MtoLicenseReviewPage /> },
{ path: 'waiver', element: <WaiverQueuePage /> },
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
],
},
],

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -277,7 +277,7 @@ export function VesselRegistrationPage() {
{[
{ label: 'Submitted', done: true },
{ label: 'Under Review', done: registration.status !== 'Pending' },
{ label: 'Approved', done: registration.status === 'Approved' },
{ label: 'Approved', done: false },
].map((step) => (
<Group key={step.label} gap="xs">
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -17,6 +17,13 @@ export const am: Translations = {
vesselRegistration: 'የመርከብ ምዝገባ',
vesselRegistrationDashboard: 'የመርከብ ምዝገባ ዳሽቦርድ',
ownershipTransfer: 'የባለቤትነት ዝውውር',
logisticsDashboard: 'የሎጂስቲክስ ፈቃዶች ዳሽቦርድ',
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
combinedLicense: 'የተቀናጀ ፈቃድ',
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
mtoLicense: 'የMTO ፈቃድ',
waiver: 'ነፃ ፈቃድ',
dashboard: 'ዳሽቦርድ',
seafarerRegistry: 'የመርከበኞች ምዝገባ',
myApplication: 'ማመልከቻዬ',

View File

@@ -18,6 +18,13 @@ export const en = {
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
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',
endorsements: 'Endorsements',
documents: 'My Documents',

View File

@@ -11,8 +11,12 @@ import {
IconRubberStamp,
IconSend,
IconShieldCheck,
IconShieldOff,
IconShip,
IconStack2,
IconTruck,
IconUserCircle,
IconUsers,
} from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
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', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
{ 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: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
{ 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': { i18nKey: 'nav.vesselRegistration' },
'/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' },
'/seaman-book': { i18nKey: 'nav.myApplication' },
'/certificates': { i18nKey: 'nav.certificates' },

View File

@@ -39,6 +39,24 @@ import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
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([
// Public auth pages
@@ -95,6 +113,24 @@ export const router = createBrowserRouter([
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
{ 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
{ path: '/profile', element: <ProfilePage /> },