feat: add Shipping Agent License Renewal and Waiver Application pages

- Implement ShippingAgentLicenseRenewalPage for submitting renewal documents with file uploads.
- Create WaiverApplicationPage for multi-step waiver application process, including document uploads and review.
- Add WaiverPage to display application status and details, including waiver letter download functionality.
- Introduce document handling components and validation for required fields in both applications.
This commit is contained in:
fitse-yotor
2026-07-28 14:46:06 +03:00
parent 4c01c5321b
commit e39f80941e
39 changed files with 9900 additions and 0 deletions

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

@@ -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 /> },