mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 21:08:13 +00:00
feat: add Shipping Agent License Renewal and Waiver Application pages
- Implement ShippingAgentLicenseRenewalPage for submitting renewal documents with file uploads. - Create WaiverApplicationPage for multi-step waiver application process, including document uploads and review. - Add WaiverPage to display application status and details, including waiver letter download functionality. - Introduce document handling components and validation for required fields in both applications.
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Company Information' },
|
||||
{ label: 'Shipping Agreement & Bank Letter' },
|
||||
{ label: 'Vehicle, Office & Terminal' },
|
||||
{ label: 'Employees' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Signed agreement with a shipping company', required: true, icon: IconFileDescription },
|
||||
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
|
||||
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||
{ key: 'canvasserDocs', label: 'Canvasser Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||
{ key: 'adminDocs', label: 'Administrative Staff Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||
{ key: 'ceoDocs', label: 'CEO / General Manager Profile & Work Experience', description: 'Profile, CV, and work experience evidence', required: true, icon: IconFileDescription },
|
||||
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
export function CombinedLicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tradeName, setTradeName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [businessAddress, setBusinessAddress] = useState('');
|
||||
const [officeAddress, setOfficeAddress] = useState('');
|
||||
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||
|
||||
const [shippingCompanyName, setShippingCompanyName] = useState('');
|
||||
const [agreementRefNumber, setAgreementRefNumber] = useState('');
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [accountHolderName, setAccountHolderName] = useState('');
|
||||
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||
|
||||
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||
const [plateNumber, setPlateNumber] = useState('');
|
||||
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||
const [terminalName, setTerminalName] = useState('');
|
||||
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||
|
||||
const [bookingClerkName, setBookingClerkName] = useState('');
|
||||
const [canvasserName, setCanvasserName] = useState('');
|
||||
const [ceoName, setCeoName] = useState('');
|
||||
const [adminStaffName, setAdminStaffName] = useState('');
|
||||
const [transit1Name, setTransit1Name] = useState('');
|
||||
const [transit2Name, setTransit2Name] = useState('');
|
||||
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||
);
|
||||
if (active === 1) return (
|
||||
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
|
||||
!!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000
|
||||
);
|
||||
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
|
||||
if (active === 3) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
|
||||
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-licenses/combined',
|
||||
method: 'POST',
|
||||
body: {
|
||||
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||
businessAddress, officeAddress, applicantType, ownershipType,
|
||||
shippingCompanyName, agreementRefNumber, bankName, accountHolderName, capitalAmount,
|
||||
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
|
||||
bookingClerkName, canvasserName, ceoName, adminStaffName, transit1Name, transit2Name,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Combined License application submitted successfully!');
|
||||
navigate('/combined-license');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Combined License Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Company Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Shipping Company Agreement" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} mt="md">
|
||||
Bank letter must show a minimum combined capital of 1,500,000 ETB (no separate amounts required per license type).
|
||||
</Alert>
|
||||
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Capital Amount (ETB)"
|
||||
required
|
||||
min={0}
|
||||
value={capitalAmount}
|
||||
onChange={setCapitalAmount}
|
||||
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Vehicle Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Office Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Terminal Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
|
||||
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented / Agreement']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
This combined license requires both Shipping Agent staff roles and two ERB-qualified transit/customs employees.
|
||||
</Alert>
|
||||
<SectionHead title="Shipping Agent Employee Profiles" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
|
||||
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
|
||||
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Freight Forwarder Transit Employees" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
|
||||
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 5 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="Trade Name" value={tradeName} />
|
||||
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||
<ReviewRow label="Business Address" value={businessAddress} />
|
||||
<ReviewRow label="Office Address" value={officeAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Shipping Agreement & Bank Letter</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
|
||||
<ReviewRow label="Bank Name" value={bankName} />
|
||||
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||
<ReviewRow label="Terminal" value={terminalName} />
|
||||
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
|
||||
<ReviewRow label="Canvasser" value={canvasserName} />
|
||||
<ReviewRow label="CEO" value={ceoName} />
|
||||
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||
<ReviewRow label="Transit Employee 1" value={transit1Name} />
|
||||
<ReviewRow label="Transit Employee 2" value={transit2Name} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/combined-license') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||
|
||||
interface CombinedLicenseApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombinedLicensePage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<CombinedLicenseApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/combined/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Combined Shipping Agent + Freight Forwarder License</Title>
|
||||
<Text fz="sm" c="dimmed">Apply for a single license covering both shipping agency and freight forwarding services</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for a Combined License</Text>
|
||||
<Text fz="sm" c="dimmed">Fulfill both Shipping Agent and Freight Forwarder requirements in a single application</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Shipping company agreement" />
|
||||
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
|
||||
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||
<RequirementItem label="Office title deed or office rental agreement" />
|
||||
<RequirementItem label="Terminal agreement or terminal title deed" />
|
||||
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff, and CEO profiles" />
|
||||
<RequirementItem label="Two ERB-qualified transit/customs employees" />
|
||||
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/combined-license/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About the Combined License</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
This single license lets you legally provide both freight forwarding and shipping agency services.
|
||||
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Payment Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{application.status === 'Certificate Issued' && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||
Your Combined License certificate is ready. Valid until {application.expiryDate}.
|
||||
</Alert>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Combined License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const RENEWAL_DOCS: DocSlot[] = [
|
||||
{ key: 'prevCertificate', label: 'Previous Combined License Certificate', description: 'Your current/expiring certificate', required: true },
|
||||
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months (Freight Forwarder requirement)', required: true },
|
||||
{ key: 'clearanceEvidence', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months (Shipping Agent requirement)', required: true },
|
||||
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
];
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombinedLicenseRenewalPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({ url: '/logistics-licenses/combined/renew', method: 'POST', body: {} }).unwrap();
|
||||
notify.success('Renewal request submitted successfully!');
|
||||
navigate('/combined-license');
|
||||
} catch {
|
||||
notify.error('Renewal submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/combined-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Renew Combined License</Title>
|
||||
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Renewal applies the controls of both the Shipping Agent and Freight Forwarder licenses. If your vehicle or
|
||||
office rental agreement has expired since your last submission, an updated agreement is required.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||
<Stack gap="md">
|
||||
{RENEWAL_DOCS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Renewal Request
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Company Information' },
|
||||
{ label: 'Bank Letter' },
|
||||
{ label: 'Vehicle & Office' },
|
||||
{ label: 'Employees' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'ceoCv', label: 'CEO CV & Work Agreement', description: 'CEO / General Manager CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'adminCv', label: 'Administrative Staff CV & Work Agreement', description: 'Administrative staff CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'transit1Erb', label: 'Transit Employee 1 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||
{ key: 'transit1Cv', label: 'Transit Employee 1 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'transit2Erb', label: 'Transit Employee 2 — ERB Certificate', description: 'Ethiopian Revenue Bureau qualification certificate', required: true, icon: IconShieldCheck },
|
||||
{ key: 'transit2Cv', label: 'Transit Employee 2 — CV & Work Agreement', description: 'CV and work agreement', required: true, icon: IconFileDescription },
|
||||
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
export function FreightForwarderLicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tradeName, setTradeName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [businessAddress, setBusinessAddress] = useState('');
|
||||
const [officeAddress, setOfficeAddress] = useState('');
|
||||
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||
|
||||
// Step 1 — Bank Letter
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [accountHolderName, setAccountHolderName] = useState('');
|
||||
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||
|
||||
// Step 2 — Vehicle & Office
|
||||
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||
const [plateNumber, setPlateNumber] = useState('');
|
||||
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||
|
||||
// Step 3 — Employees
|
||||
const [ceoName, setCeoName] = useState('');
|
||||
const [adminStaffName, setAdminStaffName] = useState('');
|
||||
const [transit1Name, setTransit1Name] = useState('');
|
||||
const [transit2Name, setTransit2Name] = useState('');
|
||||
|
||||
// Step 4 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||
);
|
||||
if (active === 1) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1500000;
|
||||
if (active === 2) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership;
|
||||
if (active === 3) return !!ceoName.trim() && !!adminStaffName.trim() && !!transit1Name.trim() && !!transit2Name.trim();
|
||||
if (active === 4) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-licenses/freight-forwarder',
|
||||
method: 'POST',
|
||||
body: {
|
||||
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||
businessAddress, officeAddress, applicantType, ownershipType,
|
||||
bankName, accountHolderName, capitalAmount,
|
||||
vehicleOwnership, plateNumber, officeOwnership,
|
||||
ceoName, adminStaffName, transit1Name, transit2Name,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Freight Forwarder License application submitted successfully!');
|
||||
navigate('/freight-forwarder-license');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Freight Forwarder License Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Company Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Bank letter must show a minimum capital of 1,500,000 ETB.
|
||||
</Alert>
|
||||
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Capital Amount (ETB)"
|
||||
required
|
||||
min={0}
|
||||
value={capitalAmount}
|
||||
onChange={setCapitalAmount}
|
||||
error={capitalAmount && Number(capitalAmount) < 1500000 ? 'Must be at least 1,500,000 ETB' : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Vehicle Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Office Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
At least two qualified transit/customs employees certified by the Ethiopian Revenue Bureau are required.
|
||||
</Alert>
|
||||
<SectionHead title="Employee Profiles" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||
<TextInput label="Transit/Customs Employee 1 Name" required value={transit1Name} onChange={(e) => setTransit1Name(e.currentTarget.value)} />
|
||||
<TextInput label="Transit/Customs Employee 2 Name" required value={transit2Name} onChange={(e) => setTransit2Name(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 5 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="Trade Name" value={tradeName} />
|
||||
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||
<ReviewRow label="Business Address" value={businessAddress} />
|
||||
<ReviewRow label="Office Address" value={officeAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Bank Name" value={bankName} />
|
||||
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Vehicle, Office & Employees</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||
<ReviewRow label="CEO" value={ceoName} />
|
||||
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||
<ReviewRow label="Transit Employee 1" value={transit1Name} />
|
||||
<ReviewRow label="Transit Employee 2" value={transit2Name} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/freight-forwarder-license') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconTruck size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||
|
||||
interface FreightForwarderApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreightForwarderLicensePage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<FreightForwarderApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<FreightForwarderApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/freight-forwarder/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Freight Forwarder License</Title>
|
||||
<Text fz="sm" c="dimmed">Apply for and manage your Freight Forwarder License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconTruck size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for a Freight Forwarder License</Text>
|
||||
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, and employee information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Bank letter showing at least 1.5 million ETB" />
|
||||
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||
<RequirementItem label="Office title deed or office rental agreement" />
|
||||
<RequirementItem label="CEO and administrative staff CVs and work agreements" />
|
||||
<RequirementItem label="Two qualified transit/customs employees (ERB certified)" />
|
||||
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconTruck size={18} />} onClick={() => navigate('/freight-forwarder-license/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About Freight Forwarder Licensing</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Payment Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconTruck size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{application.status === 'Certificate Issued' && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||
Your Freight Forwarder License certificate is ready. Valid until {application.expiryDate}.
|
||||
</Alert>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Freight Forwarder License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { FileButton } from '@mantine/core';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const RENEWAL_DOCS: DocSlot[] = [
|
||||
{ key: 'prevCertificate', label: 'Previous Freight Forwarder Certificate', description: 'Your current/expiring certificate', required: true },
|
||||
{ key: 'payroll', label: 'Three-Month Employee Payroll', description: 'Payroll evidence for the last three months', required: true },
|
||||
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
];
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreightForwarderLicenseRenewalPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({ url: '/logistics-licenses/freight-forwarder/renew', method: 'POST', body: {} }).unwrap();
|
||||
notify.success('Renewal request submitted successfully!');
|
||||
navigate('/freight-forwarder-license');
|
||||
} catch {
|
||||
notify.error('Renewal submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconTruck size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Renew Freight Forwarder License</Title>
|
||||
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||
<Stack gap="md">
|
||||
{RENEWAL_DOCS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Renewal Request
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconBuildingBank,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Company Information' },
|
||||
{ label: 'JV / Ownership Information' },
|
||||
{ label: 'Organizational Experience' },
|
||||
{ label: 'Transit Professionals' },
|
||||
{ label: 'Logistics Capacity' },
|
||||
{ label: 'Capital Information' },
|
||||
{ label: 'Business License & Registration' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'applicationLetter', label: 'Application Letter / Online Form', description: 'Signed application letter or completed online form', required: true, icon: IconFileDescription },
|
||||
{ key: 'establishmentDoc', label: 'Company Establishment Document', description: 'Company establishment / formation document', required: true, icon: IconId },
|
||||
{ key: 'memorandum', label: 'Memorandum / Articles of Association / Bylaw', description: 'Memorandum, articles of association, or bylaw document', required: true, icon: IconId },
|
||||
{ key: 'orgProfile', label: 'Organizational Profile', description: 'Organizational profile document', required: true, icon: IconFileDescription },
|
||||
{ key: 'renewedBusinessLicense', label: 'Renewed Business License', description: 'Current renewed business license', required: true, icon: IconId },
|
||||
{ key: 'commercialRegCert', label: 'Commercial Registration Certificate', description: 'Commercial registration certificate', required: true, icon: IconId },
|
||||
{ key: 'sectorEvidence', label: 'Evidence Company Is Active in Sector', description: 'Evidence the company is active in the sector', required: true, icon: IconFileDescription },
|
||||
{ key: 'transitTrainingCert', label: 'Customs Transit Training Certificate', description: 'Training certificate covering all transit professionals', required: true, icon: IconShieldCheck },
|
||||
{ key: 'employmentContracts', label: 'Employment Contracts — 3 Transit Professionals', description: 'Employment contracts for all three transit professionals', required: true, icon: IconFileDescription },
|
||||
{ key: 'educationEvidence', label: 'Education Evidence', description: 'Education evidence for transit professionals', required: true, icon: IconFileDescription },
|
||||
{ key: 'workExperienceEvidence', label: 'Work Experience Evidence', description: 'Work experience evidence for transit professionals', required: true, icon: IconFileDescription },
|
||||
{ key: 'payrollEvidence', label: 'Three-Month Payroll Evidence', description: 'Payroll evidence for the last three months', required: true, icon: IconFileDescription },
|
||||
{ key: 'taxPaymentEvidence', label: 'Monthly Tax Payment Evidence', description: 'Monthly tax payment evidence', required: true, icon: IconFileDescription },
|
||||
{ key: 'facilityEvidence', label: 'Vehicle / Machinery / Warehouse Evidence', description: 'Evidence of vehicle, machinery, or warehouse capacity', required: true, icon: IconId },
|
||||
{ key: 'vehicleLibre', label: 'Vehicle Libre / Registration Copy', description: 'Vehicle libre or registration copy', required: true, icon: IconId },
|
||||
{ key: 'rentAgreement', label: 'Legal House / Office Rent Agreement', description: 'Legal house or office rent agreement (or ownership evidence)', required: true, icon: IconId },
|
||||
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Bank confirmation letter for capital', required: true, icon: IconFileDescription },
|
||||
{ key: 'capitalBalanceEvidence', label: 'Capital Balance Evidence', description: 'Evidence of capital balance', required: true, icon: IconFileDescription },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for future certificate/ID issuance', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
export function JointInvestmentLicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [businessAddress, setBusinessAddress] = useState('');
|
||||
const [officeAddress, setOfficeAddress] = useState('');
|
||||
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||
const [contactPersonName, setContactPersonName] = useState('');
|
||||
|
||||
// Step 1 — JV / Ownership Information
|
||||
const [jvType, setJvType] = useState<string | null>(null);
|
||||
const [shareholderName, setShareholderName] = useState('');
|
||||
const [shareholderNationality, setShareholderNationality] = useState('');
|
||||
const [sharePercentage, setSharePercentage] = useState<string | number>('');
|
||||
const [capitalContribution, setCapitalContribution] = useState<string | number>('');
|
||||
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
|
||||
const [beneficialOwnershipDeclaration, setBeneficialOwnershipDeclaration] = useState('');
|
||||
|
||||
// Step 2 — Organizational Experience Information
|
||||
const [organizationalProfile, setOrganizationalProfile] = useState('');
|
||||
const [pastWorkExperienceDescription, setPastWorkExperienceDescription] = useState('');
|
||||
|
||||
// Step 3 — Transit Professional Information
|
||||
const [transiter1Name, setTransiter1Name] = useState('');
|
||||
const [transiter1Position, setTransiter1Position] = useState('');
|
||||
const [transiter2Name, setTransiter2Name] = useState('');
|
||||
const [transiter2Position, setTransiter2Position] = useState('');
|
||||
const [transiter3Name, setTransiter3Name] = useState('');
|
||||
const [transiter3Position, setTransiter3Position] = useState('');
|
||||
|
||||
// Step 4 — Logistics Capacity Information
|
||||
const [vehicleInfo, setVehicleInfo] = useState('');
|
||||
const [machineryInfo, setMachineryInfo] = useState('');
|
||||
const [warehouseInfo, setWarehouseInfo] = useState('');
|
||||
const [cargoFacilityInfo, setCargoFacilityInfo] = useState('');
|
||||
const [logisticsFacilityOwnershipType, setLogisticsFacilityOwnershipType] = useState<string | null>(null);
|
||||
|
||||
// Step 5 — Capital Information
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [accountHolderName, setAccountHolderName] = useState('');
|
||||
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||
const [totalCapitalAmount, setTotalCapitalAmount] = useState<string | number>('');
|
||||
|
||||
// Step 6 — Business License and Registration Information
|
||||
const [budgetYear, setBudgetYear] = useState('');
|
||||
const [licenseValidityDate, setLicenseValidityDate] = useState('');
|
||||
|
||||
// Step 7 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!companyName.trim() && !!tinNumber.trim() &&
|
||||
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||
);
|
||||
if (active === 1) return (
|
||||
!!jvType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
|
||||
!!sharePercentage && !!capitalContribution && !!beneficialOwnershipDeclaration.trim()
|
||||
);
|
||||
if (active === 2) return !!organizationalProfile.trim() && !!pastWorkExperienceDescription.trim();
|
||||
if (active === 3) return (
|
||||
!!transiter1Name.trim() && !!transiter1Position.trim() &&
|
||||
!!transiter2Name.trim() && !!transiter2Position.trim() &&
|
||||
!!transiter3Name.trim() && !!transiter3Position.trim()
|
||||
);
|
||||
if (active === 4) return !!vehicleInfo.trim() && !!logisticsFacilityOwnershipType;
|
||||
if (active === 5) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && !!totalCapitalAmount;
|
||||
if (active === 6) return !!budgetYear.trim() && !!licenseValidityDate.trim();
|
||||
if (active === 7) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-licenses/joint-investment',
|
||||
method: 'POST',
|
||||
body: {
|
||||
companyName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||
businessAddress, officeAddress, applicantType, ownershipType, contactPersonName,
|
||||
jvType, shareholderName, shareholderNationality, sharePercentage, capitalContribution,
|
||||
investmentPermitNumber, beneficialOwnershipDeclaration,
|
||||
organizationalProfile, pastWorkExperienceDescription,
|
||||
transiter1Name, transiter1Position, transiter2Name, transiter2Position, transiter3Name, transiter3Position,
|
||||
vehicleInfo, machineryInfo, warehouseInfo, cargoFacilityInfo, logisticsFacilityOwnershipType,
|
||||
bankName, accountHolderName, capitalAmount, totalCapitalAmount,
|
||||
budgetYear, licenseValidityDate,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Joint Investment / JV Business License application submitted successfully!');
|
||||
navigate('/joint-investment-license');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Joint Investment / JV Business License Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Company Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Trade Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Contact Person Name (optional)" value={contactPersonName} onChange={(e) => setContactPersonName(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Provide details of the joint venture / joint investment ownership structure.
|
||||
</Alert>
|
||||
<SectionHead title="JV / Ownership Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<Select label="JV / Joint Investment Type" required data={['Local-Foreign JV', 'Foreign-Foreign JV', 'Local-Local JV']} value={jvType} onChange={setJvType} />
|
||||
<TextInput label="Shareholder / Partner Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
|
||||
<TextInput label="Shareholder / Partner Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
|
||||
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
|
||||
<NumberInput label="Capital Contribution (ETB)" required min={0} value={capitalContribution} onChange={setCapitalContribution} />
|
||||
<TextInput label="Investment Permit Number (if applicable)" value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label="Beneficial Ownership Declaration"
|
||||
required
|
||||
placeholder="Describe the beneficial owners of the company..."
|
||||
value={beneficialOwnershipDeclaration}
|
||||
onChange={(e) => setBeneficialOwnershipDeclaration(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Organizational Experience Information" />
|
||||
<Textarea
|
||||
label="Organizational Profile"
|
||||
required
|
||||
placeholder="Describe the organization's structure, history, and prior work..."
|
||||
value={organizationalProfile}
|
||||
onChange={(e) => setOrganizationalProfile(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Textarea
|
||||
label="Past Work Experience Description"
|
||||
required
|
||||
placeholder="Describe relevant past work experience..."
|
||||
value={pastWorkExperienceDescription}
|
||||
onChange={(e) => setPastWorkExperienceDescription(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
At least three transit professionals/transiters are required.
|
||||
</Alert>
|
||||
<SectionHead title="Transit Professional 1" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Full Name" required value={transiter1Name} onChange={(e) => setTransiter1Name(e.currentTarget.value)} />
|
||||
<TextInput label="Position" required value={transiter1Position} onChange={(e) => setTransiter1Position(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Transit Professional 2" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Full Name" required value={transiter2Name} onChange={(e) => setTransiter2Name(e.currentTarget.value)} />
|
||||
<TextInput label="Position" required value={transiter2Position} onChange={(e) => setTransiter2Position(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Transit Professional 3" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Full Name" required value={transiter3Name} onChange={(e) => setTransiter3Name(e.currentTarget.value)} />
|
||||
<TextInput label="Position" required value={transiter3Position} onChange={(e) => setTransiter3Position(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Logistics Capacity Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Vehicle Information" required value={vehicleInfo} onChange={(e) => setVehicleInfo(e.currentTarget.value)} />
|
||||
<TextInput label="Machinery Information (if applicable)" value={machineryInfo} onChange={(e) => setMachineryInfo(e.currentTarget.value)} />
|
||||
<TextInput label="Warehouse Information (if applicable)" value={warehouseInfo} onChange={(e) => setWarehouseInfo(e.currentTarget.value)} />
|
||||
<TextInput label="Cargo Receiving Facility Information (if applicable)" value={cargoFacilityInfo} onChange={(e) => setCargoFacilityInfo(e.currentTarget.value)} />
|
||||
<Select label="Logistics Facility Ownership Type" required data={['Owned', 'Rented']} value={logisticsFacilityOwnershipType} onChange={setLogisticsFacilityOwnershipType} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 5 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
The capital amount is subject to a minimum threshold configured by EMA. No fixed threshold is enforced here.
|
||||
</Alert>
|
||||
<SectionHead title="Capital Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||
<NumberInput label="Capital Amount (ETB)" required min={0} value={capitalAmount} onChange={setCapitalAmount} />
|
||||
<NumberInput label="Total Capital Amount (ETB)" required min={0} value={totalCapitalAmount} onChange={setTotalCapitalAmount} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 6 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Business License and Registration Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Budget Year" required value={budgetYear} onChange={(e) => setBudgetYear(e.currentTarget.value)} />
|
||||
<TextInput label="License Validity Date" required placeholder="YYYY-MM-DD" value={licenseValidityDate} onChange={(e) => setLicenseValidityDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 7 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 8 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||
<ReviewRow label="Business Address" value={businessAddress} />
|
||||
<ReviewRow label="Office Address" value={officeAddress} />
|
||||
<ReviewRow label="Contact Person" value={contactPersonName} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">JV / Ownership Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="JV Type" value={jvType ?? ''} />
|
||||
<ReviewRow label="Shareholder / Partner" value={shareholderName} />
|
||||
<ReviewRow label="Nationality" value={shareholderNationality} />
|
||||
<ReviewRow label="Share Percentage" value={sharePercentage ? `${sharePercentage}%` : ''} />
|
||||
<ReviewRow label="Capital Contribution" value={capitalContribution ? `${Number(capitalContribution).toLocaleString()} ETB` : ''} />
|
||||
<ReviewRow label="Investment Permit No." value={investmentPermitNumber} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Transit Professionals</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Transit Professional 1" value={`${transiter1Name} — ${transiter1Position}`} />
|
||||
<ReviewRow label="Transit Professional 2" value={`${transiter2Name} — ${transiter2Position}`} />
|
||||
<ReviewRow label="Transit Professional 3" value={`${transiter3Name} — ${transiter3Position}`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Capital Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Bank Name" value={bankName} />
|
||||
<ReviewRow label="Capital Amount" value={capitalAmount ? `${Number(capitalAmount).toLocaleString()} ETB` : ''} />
|
||||
<ReviewRow label="Total Capital Amount" value={totalCapitalAmount ? `${Number(totalCapitalAmount).toLocaleString()} ETB` : ''} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/joint-investment-license') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconBuildingBank size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconClockHour4,
|
||||
IconInfoCircle,
|
||||
IconBuildingBank,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Completed';
|
||||
|
||||
interface JointInvestmentApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
Completed: 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function JointInvestmentLicensePage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<JointInvestmentApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<JointInvestmentApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/joint-investment/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Joint Investment / JV Business License</Title>
|
||||
<Text fz="sm" c="dimmed">Apply for and manage your Joint Investment / JV Business License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconBuildingBank size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for a Joint Investment / JV Business License</Text>
|
||||
<Text fz="sm" c="dimmed">Provide company, JV/ownership, organizational, transit, logistics, and capital information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Company establishment and JV/ownership documentation" />
|
||||
<RequirementItem label="Organizational profile and past work experience evidence" />
|
||||
<RequirementItem label="At least three qualified transit professionals" />
|
||||
<RequirementItem label="Vehicle, machinery, or warehouse evidence as applicable" />
|
||||
<RequirementItem label="Bank confirmation letter and capital evidence" />
|
||||
<RequirementItem label="Passport-size photo for future certificate issuance" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconBuildingBank size={18} />} onClick={() => navigate('/joint-investment-license/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About Joint Investment / JV Business Licensing</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
This is a one-time evaluation license. Joint Investment / JV Business applications do not
|
||||
require annual renewal once a decision is reached.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconBuildingBank size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Completed' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||
{ label: 'Approved', done: ['Approved', 'Completed'].includes(application.status) },
|
||||
{ label: 'Completed', done: (application.status as LicenseStatus) === 'Completed' },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{application.status === 'Completed' && (
|
||||
<Alert color="teal" icon={<IconCheck size={17} />}>
|
||||
Your Joint Investment / JV Business License application is complete. The decision and audit
|
||||
history have been recorded.
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Alert, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { IconArrowLeft, IconInfoCircle, IconBuildingBank } from '@tabler/icons-react';
|
||||
|
||||
export function JointInvestmentLicenseRenewalPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconBuildingBank size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Joint Investment / JV Business License Renewal</Title>
|
||||
<Text fz="sm" c="dimmed">Renewal information for your Joint Investment / JV Business License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />} title="Renewal Not Applicable">
|
||||
The Joint Investment / JV Business License is issued as a one-time evaluation and does not
|
||||
require annual renewal. Once your application is approved and completed, no further renewal
|
||||
action is needed for this license type.
|
||||
</Alert>
|
||||
<Button mt="lg" leftSection={<IconArrowLeft size={16} />} onClick={() => navigate('/joint-investment-license')}>
|
||||
Back to Joint Investment License
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued' | 'Completed';
|
||||
|
||||
interface LicenseSummary {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green', Completed: 'green',
|
||||
};
|
||||
|
||||
const LICENSE_LINES = [
|
||||
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', apiPath: '/logistics-licenses/freight-forwarder/my', icon: IconTruck, color: 'blue' },
|
||||
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', apiPath: '/logistics-licenses/shipping-agent/my', icon: IconShip, color: 'indigo' },
|
||||
{ key: 'combined', label: 'Combined License', route: '/combined-license', apiPath: '/logistics-licenses/combined/my', icon: IconStack2, color: 'violet' },
|
||||
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', apiPath: '/logistics-licenses/joint-investment/my', icon: IconUsers, color: 'grape' },
|
||||
{ key: 'mto', label: 'MTO License', route: '/mto-license', apiPath: '/logistics-licenses/mto/my', icon: IconTruck, color: 'cyan' },
|
||||
{ key: 'waiver', label: 'Waiver', route: '/waiver', apiPath: '/logistics-licenses/waiver/my', icon: IconShieldOff, color: 'orange' },
|
||||
] as const;
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Resubmit Required', 'Payment Pending', 'Payment Confirmed']);
|
||||
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
|
||||
|
||||
export function LogisticsDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<Record<string, LicenseSummary | null>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchTrigger] = useApiMutation<LicenseSummary>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
|
||||
Promise.allSettled(
|
||||
LICENSE_LINES.map((line) => fetchTrigger({ url: line.apiPath, method: 'GET' }).unwrap())
|
||||
).then((results) => {
|
||||
const next: Record<string, LicenseSummary | null> = {};
|
||||
results.forEach((r, i) => {
|
||||
next[LICENSE_LINES[i].key] = r.status === 'fulfilled' ? r.value : null;
|
||||
});
|
||||
setApps(next);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const values = Object.values(apps).filter((a): a is LicenseSummary => !!a);
|
||||
const stats = {
|
||||
total: values.length,
|
||||
active: values.filter((a) => ACTIVE_STATUSES.has(a.status)).length,
|
||||
issued: values.filter((a) => ISSUED_STATUSES.has(a.status)).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
|
||||
<IconStack2 size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>My Logistics Licenses Dashboard</Title>
|
||||
<Text fz="sm" c="dimmed">Track your freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
<Skeleton height={80} radius="md" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Applications Filed</Text>
|
||||
<Text fz="xl" fw={700} c="indigo.6" mt={4}>{stats.total}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Active / In Progress</Text>
|
||||
<Text fz="xl" fw={700} c="yellow.7" mt={4}>{stats.active}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificates Issued</Text>
|
||||
<Text fz="xl" fw={700} c="teal.6" mt={4}>{stats.issued}</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">License Applications</Text>
|
||||
<Stack gap={0}>
|
||||
{LICENSE_LINES.map((line, i) => {
|
||||
const app = apps[line.key];
|
||||
return (
|
||||
<Group
|
||||
key={line.key}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: i < LICENSE_LINES.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => navigate(app ? line.route : `${line.route}/apply`)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color={line.color} variant="light">
|
||||
<line.icon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text size="sm" fw={600}>{line.label}</Text>
|
||||
<Text size="xs" c="dimmed">{app ? app.id : 'Not applied yet'}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{loading ? (
|
||||
<Skeleton height={22} width={90} radius="xl" />
|
||||
) : app ? (
|
||||
<Badge variant="light" color={STATUS_COLOR[app.status] ?? 'gray'} radius="sm">
|
||||
{app.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button size="xs" variant="light">Apply</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconBuildingWarehouse,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Company Information' },
|
||||
{ label: 'Ownership Information' },
|
||||
{ label: 'Management & Board' },
|
||||
{ label: 'Employee Information' },
|
||||
{ label: 'Terminal & Facility' },
|
||||
{ label: 'Fleet & Equipment' },
|
||||
{ label: 'Financial Capacity' },
|
||||
{ label: 'Insurance, Bond & Liability' },
|
||||
{ label: 'Agent Network & ICT' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||
{ key: 'businessLicense', label: 'Business License / Trade Registration', description: 'Valid business license', required: true, icon: IconId },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||
{ key: 'memorandum', label: 'Memorandum and Articles of Association', description: 'Company formation documents', required: true, icon: IconFileDescription },
|
||||
{ key: 'investmentPermit', label: 'Investment Permit', description: 'Required for foreign or joint venture ownership', required: false, icon: IconFileDescription },
|
||||
{ key: 'capitalProof', label: 'Paid-up Capital Proof', description: 'Evidence of paid-up capital', required: true, icon: IconFileDescription },
|
||||
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', description: 'Letter confirming capital deposit', required: true, icon: IconFileDescription },
|
||||
{ key: 'boardCvs', label: 'Board Member CVs', description: 'CVs of all board members', required: true, icon: IconFileDescription },
|
||||
{ key: 'managerCv', label: 'Manager CV and Qualification Documents', description: 'General Manager CV and qualifications', required: true, icon: IconFileDescription },
|
||||
{ key: 'employeeQualDocs', label: 'Employee Qualification Documents', description: 'Qualification documents for key employees', required: true, icon: IconFileDescription },
|
||||
{ key: 'terminalLease', label: 'Terminal Lease or Title Document', description: 'Terminal ownership or lease document', required: true, icon: IconBuildingWarehouse },
|
||||
{ key: 'warehouseEvidence', label: 'Warehouse / Terminal Evidence', description: 'Photos or inspection evidence of warehouse/terminal', required: false, icon: IconBuildingWarehouse },
|
||||
{ key: 'truckOwnership', label: 'Truck Ownership or Rental Agreement', description: 'Vehicle registration or rental contract', required: true, icon: IconTruck },
|
||||
{ key: 'equipmentOwnership', label: 'Equipment Ownership or Rental Agreement', description: 'Required if equipment is rented', required: false, icon: IconTruck },
|
||||
{ key: 'insuranceCert', label: 'Insurance Certificate', description: 'Cargo and multimodal transport liability insurance', required: true, icon: IconShieldCheck },
|
||||
{ key: 'customsBond', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck },
|
||||
{ key: 'agentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription },
|
||||
{ key: 'branchContract', label: 'Branch Office Contract', description: 'Branch office lease or ownership contract', required: false, icon: IconFileDescription },
|
||||
{ key: 'operationalManual', label: 'Administrative / Operational Manual', description: 'Operational procedures manual', required: true, icon: IconFileDescription },
|
||||
{ key: 'ictEvidence', label: 'ICT Capability Evidence', description: 'Evidence of ICT / tracking system capability', required: true, icon: IconFileDescription },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
export function MtoLicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 0 — Company / Applicant Information (email/phone auto-fetched from account, not shown here)
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tradeName, setTradeName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||
const [ownershipTypeCompany, setOwnershipTypeCompany] = useState<string | null>(null);
|
||||
const [businessAddress, setBusinessAddress] = useState('');
|
||||
const [headOfficeAddress, setHeadOfficeAddress] = useState('');
|
||||
const [hasBranchOffices, setHasBranchOffices] = useState<string | null>(null);
|
||||
const [branchOfficeAddress, setBranchOfficeAddress] = useState('');
|
||||
|
||||
// Step 1 — Ownership Information
|
||||
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||
const [shareholderName, setShareholderName] = useState('');
|
||||
const [shareholderNationality, setShareholderNationality] = useState('');
|
||||
const [sharePercentage, setSharePercentage] = useState<string | number>('');
|
||||
const [investmentClassification, setInvestmentClassification] = useState<string | null>(null);
|
||||
const [investmentPermitNumber, setInvestmentPermitNumber] = useState('');
|
||||
const [beneficialOwnershipDeclared, setBeneficialOwnershipDeclared] = useState(false);
|
||||
|
||||
// Step 2 — Management and Board Information
|
||||
const [gmName, setGmName] = useState('');
|
||||
const [gmQualification, setGmQualification] = useState('');
|
||||
const [gmExperience, setGmExperience] = useState('');
|
||||
const [boardMembers, setBoardMembers] = useState('');
|
||||
const [gmCv, setGmCv] = useState<File | null>(null);
|
||||
const [gmEducationCert, setGmEducationCert] = useState<File | null>(null);
|
||||
const [gmExperienceEvidence, setGmExperienceEvidence] = useState<File | null>(null);
|
||||
|
||||
// Step 3 — Employee Information
|
||||
const [empFullName, setEmpFullName] = useState('');
|
||||
const [empPosition, setEmpPosition] = useState('');
|
||||
const [empQualification, setEmpQualification] = useState('');
|
||||
const [empExperience, setEmpExperience] = useState('');
|
||||
const [empEmploymentType, setEmpEmploymentType] = useState<string | null>(null);
|
||||
const [empProfCert, setEmpProfCert] = useState<File | null>(null);
|
||||
const [empContract, setEmpContract] = useState<File | null>(null);
|
||||
|
||||
// Step 4 — Terminal and Facility Information
|
||||
const [terminalLocation, setTerminalLocation] = useState('');
|
||||
const [terminalSize, setTerminalSize] = useState('');
|
||||
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||
const [terminalLeaseDoc, setTerminalLeaseDoc] = useState<File | null>(null);
|
||||
const [warehouseAvailability, setWarehouseAvailability] = useState<string | null>(null);
|
||||
const [warehouseSize, setWarehouseSize] = useState('');
|
||||
const [terminalSecurityInfo, setTerminalSecurityInfo] = useState('');
|
||||
const [terminalPhotos, setTerminalPhotos] = useState<File | null>(null);
|
||||
|
||||
// Step 5 — Fleet and Equipment Information
|
||||
const [numberOfTrucks, setNumberOfTrucks] = useState<string | number>('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [truckCapacity, setTruckCapacity] = useState('');
|
||||
const [truckOwnership, setTruckOwnership] = useState<string | null>(null);
|
||||
const [vehicleRegDoc, setVehicleRegDoc] = useState<File | null>(null);
|
||||
const [vehicleRentalContract, setVehicleRentalContract] = useState<File | null>(null);
|
||||
const [cargoHandlingEquipment, setCargoHandlingEquipment] = useState('');
|
||||
const [equipmentOwnership, setEquipmentOwnership] = useState<string | null>(null);
|
||||
const [equipmentEvidence, setEquipmentEvidence] = useState<File | null>(null);
|
||||
|
||||
// Step 6 — Financial Capacity
|
||||
const [paidUpCapitalAmount, setPaidUpCapitalAmount] = useState<string | number>('');
|
||||
const [bankConfirmation, setBankConfirmation] = useState<File | null>(null);
|
||||
const [bankDepositEvidence, setBankDepositEvidence] = useState<File | null>(null);
|
||||
const [auditedFinancialStatement, setAuditedFinancialStatement] = useState<File | null>(null);
|
||||
const [assetValuationEvidence, setAssetValuationEvidence] = useState<File | null>(null);
|
||||
|
||||
// Step 7 — Insurance, Bond, and Liability
|
||||
const [cargoLiabilityInsurance, setCargoLiabilityInsurance] = useState<File | null>(null);
|
||||
const [mtoLiabilityInsurance, setMtoLiabilityInsurance] = useState<File | null>(null);
|
||||
const [customsBondDoc, setCustomsBondDoc] = useState<File | null>(null);
|
||||
const [insuranceValidityDate, setInsuranceValidityDate] = useState('');
|
||||
const [bondValidityDate, setBondValidityDate] = useState('');
|
||||
|
||||
// Step 8 — Agent Network and ICT Capability
|
||||
const [overseasAgentAgreement, setOverseasAgentAgreement] = useState<File | null>(null);
|
||||
const [localBranchInfo, setLocalBranchInfo] = useState('');
|
||||
const [ictSystemDescription, setIctSystemDescription] = useState('');
|
||||
const [cargoTrackingCapability, setCargoTrackingCapability] = useState('');
|
||||
const [communicationSystem, setCommunicationSystem] = useState('');
|
||||
const [documentManagementCapability, setDocumentManagementCapability] = useState('');
|
||||
|
||||
// Step 9 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const isForeignOrJoint = investmentClassification === 'Foreign' || investmentClassification === 'Joint';
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||
!!applicantType && !!ownershipTypeCompany &&
|
||||
!!businessAddress.trim() && !!headOfficeAddress.trim() &&
|
||||
!!hasBranchOffices && (hasBranchOffices === 'No' || !!branchOfficeAddress.trim())
|
||||
);
|
||||
if (active === 1) return (
|
||||
!!ownershipType && !!shareholderName.trim() && !!shareholderNationality.trim() &&
|
||||
!!sharePercentage && !!investmentClassification &&
|
||||
(!isForeignOrJoint || !!investmentPermitNumber.trim()) &&
|
||||
beneficialOwnershipDeclared
|
||||
);
|
||||
if (active === 2) return (
|
||||
!!gmName.trim() && !!gmQualification.trim() && !!gmExperience.trim() && !!boardMembers.trim() &&
|
||||
!!gmCv && !!gmEducationCert && !!gmExperienceEvidence
|
||||
);
|
||||
if (active === 3) return (
|
||||
!!empFullName.trim() && !!empPosition.trim() && !!empQualification.trim() &&
|
||||
!!empExperience.trim() && !!empEmploymentType
|
||||
);
|
||||
if (active === 4) return (
|
||||
!!terminalLocation.trim() && !!terminalSize.trim() && !!terminalOwnership && !!terminalLeaseDoc &&
|
||||
!!warehouseAvailability &&
|
||||
(warehouseAvailability === 'No' || (!!warehouseSize.trim() && !!terminalSecurityInfo.trim() && !!terminalPhotos))
|
||||
);
|
||||
if (active === 5) return (
|
||||
!!numberOfTrucks && !!truckPlateNumber.trim() && !!truckCapacity.trim() && !!truckOwnership && !!vehicleRegDoc &&
|
||||
(truckOwnership !== 'Rented' || !!vehicleRentalContract) &&
|
||||
!!cargoHandlingEquipment.trim() && !!equipmentOwnership &&
|
||||
(equipmentOwnership !== 'Rented' || !!equipmentEvidence)
|
||||
);
|
||||
if (active === 6) return (
|
||||
!!paidUpCapitalAmount && !!bankConfirmation && !!bankDepositEvidence
|
||||
);
|
||||
if (active === 7) return (
|
||||
!!cargoLiabilityInsurance && !!mtoLiabilityInsurance && !!customsBondDoc &&
|
||||
!!insuranceValidityDate.trim() && !!bondValidityDate.trim()
|
||||
);
|
||||
if (active === 8) return (
|
||||
!!overseasAgentAgreement && !!localBranchInfo.trim() && !!ictSystemDescription.trim() &&
|
||||
!!cargoTrackingCapability.trim() && !!communicationSystem.trim() && !!documentManagementCapability.trim()
|
||||
);
|
||||
if (active === 9) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]) &&
|
||||
(!isForeignOrJoint || !!files['investmentPermit']);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-licenses/mto',
|
||||
method: 'POST',
|
||||
body: {
|
||||
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||
applicantType, ownershipTypeCompany, businessAddress, headOfficeAddress,
|
||||
hasBranchOffices, branchOfficeAddress,
|
||||
ownershipType, shareholderName, shareholderNationality, sharePercentage,
|
||||
investmentClassification, investmentPermitNumber, beneficialOwnershipDeclared,
|
||||
gmName, gmQualification, gmExperience, boardMembers,
|
||||
empFullName, empPosition, empQualification, empExperience, empEmploymentType,
|
||||
terminalLocation, terminalSize, terminalOwnership, warehouseAvailability, warehouseSize, terminalSecurityInfo,
|
||||
numberOfTrucks, truckPlateNumber, truckCapacity, truckOwnership, cargoHandlingEquipment, equipmentOwnership,
|
||||
paidUpCapitalAmount,
|
||||
insuranceValidityDate, bondValidityDate,
|
||||
localBranchInfo, ictSystemDescription, cargoTrackingCapability, communicationSystem, documentManagementCapability,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('MTO License application submitted successfully!');
|
||||
navigate('/mto-license');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>MTO License Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Company / Applicant Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Operator Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Commercial Registration No." required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License No." required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipTypeCompany} onChange={setOwnershipTypeCompany} />
|
||||
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Head Office Address" required value={headOfficeAddress} onChange={(e) => setHeadOfficeAddress(e.currentTarget.value)} />
|
||||
<Select label="Has Branch Offices?" required data={['Yes', 'No']} value={hasBranchOffices} onChange={setHasBranchOffices} />
|
||||
{hasBranchOffices === 'Yes' && (
|
||||
<TextInput label="Branch Office Address" required value={branchOfficeAddress} onChange={(e) => setBranchOfficeAddress(e.currentTarget.value)} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Ownership Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Ownership Type" required data={['Sole', 'Partnership', 'Shareholding Company']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<TextInput label="Shareholder Name" required value={shareholderName} onChange={(e) => setShareholderName(e.currentTarget.value)} />
|
||||
<TextInput label="Shareholder Nationality" required value={shareholderNationality} onChange={(e) => setShareholderNationality(e.currentTarget.value)} />
|
||||
<NumberInput label="Share Percentage (%)" required min={0} max={100} value={sharePercentage} onChange={setSharePercentage} />
|
||||
<Select label="Investment Classification" required data={['Local', 'Foreign', 'Joint']} value={investmentClassification} onChange={setInvestmentClassification} />
|
||||
{isForeignOrJoint && (
|
||||
<TextInput label="Investment Permit Number" required value={investmentPermitNumber} onChange={(e) => setInvestmentPermitNumber(e.currentTarget.value)} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
label="I declare the beneficial ownership information provided above is accurate and complete."
|
||||
checked={beneficialOwnershipDeclared}
|
||||
onChange={(e) => setBeneficialOwnershipDeclared(e.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="General Manager" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="General Manager Name" required value={gmName} onChange={(e) => setGmName(e.currentTarget.value)} />
|
||||
<TextInput label="General Manager Qualification" required value={gmQualification} onChange={(e) => setGmQualification(e.currentTarget.value)} />
|
||||
<TextInput label="General Manager Experience" required value={gmExperience} onChange={(e) => setGmExperience(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Board of Directors" />
|
||||
<Textarea
|
||||
label="Board Member List"
|
||||
description="List each board member's name and role"
|
||||
required
|
||||
rows={3}
|
||||
value={boardMembers}
|
||||
onChange={(e) => setBoardMembers(e.currentTarget.value)}
|
||||
/>
|
||||
<SectionHead title="Supporting Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'gmCv', label: 'CV Upload', description: 'General Manager CV', required: true, icon: IconFileDescription }} file={gmCv} onFile={setGmCv} />
|
||||
<DocCard slot={{ key: 'gmEducationCert', label: 'Education Certificate Upload', description: 'General Manager education certificate', required: true, icon: IconFileDescription }} file={gmEducationCert} onFile={setGmEducationCert} />
|
||||
<DocCard slot={{ key: 'gmExperienceEvidence', label: 'Experience Evidence Upload', description: 'Proof of relevant work experience', required: true, icon: IconFileDescription }} file={gmExperienceEvidence} onFile={setGmExperienceEvidence} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Key Employee Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Full Name" required value={empFullName} onChange={(e) => setEmpFullName(e.currentTarget.value)} />
|
||||
<TextInput label="Position" required value={empPosition} onChange={(e) => setEmpPosition(e.currentTarget.value)} />
|
||||
<TextInput label="Qualification" required value={empQualification} onChange={(e) => setEmpQualification(e.currentTarget.value)} />
|
||||
<TextInput label="Experience" required value={empExperience} onChange={(e) => setEmpExperience(e.currentTarget.value)} />
|
||||
<Select label="Employment Type" required data={['Permanent', 'Contract', 'Part-Time']} value={empEmploymentType} onChange={setEmpEmploymentType} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Employee Documents" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'empProfCert', label: 'Professional Certificate', description: 'Optional professional certification', required: false, icon: IconFileDescription }} file={empProfCert} onFile={setEmpProfCert} />
|
||||
<DocCard slot={{ key: 'empContract', label: 'Employment Contract', description: 'Optional employment contract document', required: false, icon: IconFileDescription }} file={empContract} onFile={setEmpContract} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Terminal Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Terminal Location" required value={terminalLocation} onChange={(e) => setTerminalLocation(e.currentTarget.value)} />
|
||||
<TextInput label="Terminal Size" required value={terminalSize} onChange={(e) => setTerminalSize(e.currentTarget.value)} />
|
||||
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||
<DocCard slot={{ key: 'terminalLeaseDoc', label: 'Terminal Lease / Title Document', description: 'Ownership or lease document for the terminal', required: true, icon: IconBuildingWarehouse }} file={terminalLeaseDoc} onFile={setTerminalLeaseDoc} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Warehouse & Security" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Warehouse Availability" required data={['Yes', 'No']} value={warehouseAvailability} onChange={setWarehouseAvailability} />
|
||||
{warehouseAvailability === 'Yes' && (
|
||||
<TextInput label="Warehouse Size" required value={warehouseSize} onChange={(e) => setWarehouseSize(e.currentTarget.value)} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{warehouseAvailability === 'Yes' && (
|
||||
<>
|
||||
<Textarea
|
||||
label="Terminal Security / Fence Information"
|
||||
required
|
||||
rows={3}
|
||||
value={terminalSecurityInfo}
|
||||
onChange={(e) => setTerminalSecurityInfo(e.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'terminalPhotos', label: 'Terminal Photos / Inspection Evidence', description: 'Photos or evidence of terminal facility', required: true, icon: IconCamera }} file={terminalPhotos} onFile={setTerminalPhotos} />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 5 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Fleet Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<NumberInput label="Number of Trucks" required min={0} value={numberOfTrucks} onChange={setNumberOfTrucks} />
|
||||
<TextInput label="Truck Plate Number" required value={truckPlateNumber} onChange={(e) => setTruckPlateNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Truck Capacity" required value={truckCapacity} onChange={(e) => setTruckCapacity(e.currentTarget.value)} />
|
||||
<Select label="Truck Ownership Type" required data={['Owned', 'Rented']} value={truckOwnership} onChange={setTruckOwnership} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||
<DocCard slot={{ key: 'vehicleRegDoc', label: 'Vehicle Registration Document', description: 'Registration document for the fleet', required: true, icon: IconTruck }} file={vehicleRegDoc} onFile={setVehicleRegDoc} />
|
||||
{truckOwnership === 'Rented' && (
|
||||
<DocCard slot={{ key: 'vehicleRentalContract', label: 'Vehicle Rental Contract', description: 'Rental contract for the fleet', required: true, icon: IconTruck }} file={vehicleRentalContract} onFile={setVehicleRentalContract} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Cargo Handling Equipment" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Cargo Handling Equipment" required value={cargoHandlingEquipment} onChange={(e) => setCargoHandlingEquipment(e.currentTarget.value)} />
|
||||
<Select label="Equipment Ownership / Rental" required data={['Owned', 'Rented']} value={equipmentOwnership} onChange={setEquipmentOwnership} />
|
||||
</SimpleGrid>
|
||||
{equipmentOwnership === 'Rented' && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'equipmentEvidence', label: 'Equipment Ownership / Rental Evidence', description: 'Ownership or rental evidence for equipment', required: true, icon: IconTruck }} file={equipmentEvidence} onFile={setEquipmentEvidence} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 6 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Financial Capacity" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<NumberInput label="Paid-up Capital Amount (ETB)" required min={0} value={paidUpCapitalAmount} onChange={setPaidUpCapitalAmount} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||
<DocCard slot={{ key: 'bankConfirmation', label: 'Bank Confirmation', description: 'Bank letter confirming capital', required: true, icon: IconFileDescription }} file={bankConfirmation} onFile={setBankConfirmation} />
|
||||
<DocCard slot={{ key: 'bankDepositEvidence', label: 'Bank Deposit Evidence', description: 'Evidence of the capital deposit', required: true, icon: IconFileDescription }} file={bankDepositEvidence} onFile={setBankDepositEvidence} />
|
||||
<DocCard slot={{ key: 'auditedFinancialStatement', label: 'Audited Financial Statement', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={auditedFinancialStatement} onFile={setAuditedFinancialStatement} />
|
||||
<DocCard slot={{ key: 'assetValuationEvidence', label: 'Asset Valuation Evidence', description: 'Optional, if available', required: false, icon: IconFileDescription }} file={assetValuationEvidence} onFile={setAssetValuationEvidence} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 7 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Insurance, Bond & Liability" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'cargoLiabilityInsurance', label: 'Cargo Liability Insurance', description: 'Insurance covering cargo liability', required: true, icon: IconShieldCheck }} file={cargoLiabilityInsurance} onFile={setCargoLiabilityInsurance} />
|
||||
<DocCard slot={{ key: 'mtoLiabilityInsurance', label: 'Multimodal Transport Liability Insurance', description: 'Insurance covering multimodal transport liability', required: true, icon: IconShieldCheck }} file={mtoLiabilityInsurance} onFile={setMtoLiabilityInsurance} />
|
||||
<DocCard slot={{ key: 'customsBondDoc', label: 'Customs Bond Document', description: 'Customs bond documentation', required: true, icon: IconShieldCheck }} file={customsBondDoc} onFile={setCustomsBondDoc} />
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="xs">
|
||||
<TextInput label="Insurance Validity Date" required placeholder="YYYY-MM-DD" value={insuranceValidityDate} onChange={(e) => setInsuranceValidityDate(e.currentTarget.value)} />
|
||||
<TextInput label="Bond Validity Date" required placeholder="YYYY-MM-DD" value={bondValidityDate} onChange={(e) => setBondValidityDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 8 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Agent Network" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocCard slot={{ key: 'overseasAgentAgreement', label: 'Overseas Agent Agreement', description: 'Agreement with overseas agent network', required: true, icon: IconFileDescription }} file={overseasAgentAgreement} onFile={setOverseasAgentAgreement} />
|
||||
<TextInput label="Local Branch Office Information" required value={localBranchInfo} onChange={(e) => setLocalBranchInfo(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="ICT Capability" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Cargo Tracking Capability" required value={cargoTrackingCapability} onChange={(e) => setCargoTrackingCapability(e.currentTarget.value)} />
|
||||
<TextInput label="Communication System" required value={communicationSystem} onChange={(e) => setCommunicationSystem(e.currentTarget.value)} />
|
||||
<TextInput label="Document Management Capability" required value={documentManagementCapability} onChange={(e) => setDocumentManagementCapability(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<Textarea
|
||||
label="ICT System Description"
|
||||
required
|
||||
rows={3}
|
||||
value={ictSystemDescription}
|
||||
onChange={(e) => setIctSystemDescription(e.currentTarget.value)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 9 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 10 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting. Your application requires a physical inspection of your terminal, warehouse, trucks, office, and equipment.
|
||||
If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company / Applicant Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="Trade Name" value={tradeName} />
|
||||
<ReviewRow label="TIN" value={tinNumber} />
|
||||
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||
<ReviewRow label="Business License No." value={businessLicenseNumber} />
|
||||
<ReviewRow label="Applicant Type" value={applicantType ?? ''} />
|
||||
<ReviewRow label="Ownership Type" value={ownershipTypeCompany ?? ''} />
|
||||
<ReviewRow label="Business Address" value={businessAddress} />
|
||||
<ReviewRow label="Head Office Address" value={headOfficeAddress} />
|
||||
{hasBranchOffices === 'Yes' && <ReviewRow label="Branch Office Address" value={branchOfficeAddress} />}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Ownership Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Ownership Type" value={ownershipType ?? ''} />
|
||||
<ReviewRow label="Shareholder Name" value={shareholderName} />
|
||||
<ReviewRow label="Shareholder Nationality" value={shareholderNationality} />
|
||||
<ReviewRow label="Share Percentage" value={`${sharePercentage || ''}%`} />
|
||||
<ReviewRow label="Investment Classification" value={investmentClassification ?? ''} />
|
||||
{isForeignOrJoint && <ReviewRow label="Investment Permit Number" value={investmentPermitNumber} />}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Management, Employees & Terminal</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="General Manager" value={gmName} />
|
||||
<ReviewRow label="Employee" value={empFullName} />
|
||||
<ReviewRow label="Terminal Location" value={terminalLocation} />
|
||||
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
|
||||
<ReviewRow label="Warehouse Availability" value={warehouseAvailability ?? ''} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Fleet & Financial Capacity</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Number of Trucks" value={`${numberOfTrucks || ''}`} />
|
||||
<ReviewRow label="Truck Ownership" value={truckOwnership ?? ''} />
|
||||
<ReviewRow label="Paid-up Capital" value={`${Number(paidUpCapitalAmount || 0).toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{DOC_SLOTS.filter((s) => s.key !== 'investmentPermit' || isForeignOrJoint).map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/mto-license') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Inspection Pending' | 'Inspection Completed' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||
|
||||
interface MtoApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
'Inspection Pending': 'grape', 'Inspection Completed': 'indigo',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function MtoLicensePage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<MtoApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<MtoApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/mto/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Multimodal Transport Operator License</Title>
|
||||
<Text fz="sm" c="dimmed">Apply for and manage your Multimodal Transport Operator (MTO) License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for an MTO License</Text>
|
||||
<Text fz="sm" c="dimmed">Provide company, ownership, management, terminal, fleet, financial, and insurance information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Company registration, TIN, and business license documents" />
|
||||
<RequirementItem label="Ownership and beneficial ownership declaration" />
|
||||
<RequirementItem label="Management and board information with CVs" />
|
||||
<RequirementItem label="Terminal / warehouse lease or title documents" />
|
||||
<RequirementItem label="Fleet and cargo handling equipment documentation" />
|
||||
<RequirementItem label="Proof of paid-up capital and bank confirmation" />
|
||||
<RequirementItem label="Cargo and multimodal transport liability insurance, customs bond" />
|
||||
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/mto-license/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About MTO Licensing</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Applications require a physical inspection of your terminal, warehouse, trucks, office, and equipment.
|
||||
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Inspection Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Inspection Pending">
|
||||
A physical inspection of your terminal, warehouse, trucks, office, and equipment is required. An Inspector will contact you to schedule the visit.
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Inspection Completed' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="indigo" title="Inspection Completed">
|
||||
Your physical inspection has been completed by an Inspector. Your application is proceeding to the next stage.
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Payment Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' && application.status !== 'Under Review' },
|
||||
{ label: 'Inspection Pending', done: ['Inspection Pending', 'Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Inspection Completed', done: ['Inspection Completed', 'Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{application.status === 'Certificate Issued' && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||
Your Multimodal Transport Operator License certificate is ready. Valid until {application.expiryDate}.
|
||||
</Alert>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Multimodal Transport Operator License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { FileButton } from '@mantine/core';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const RENEWAL_DOCS: DocSlot[] = [
|
||||
{ key: 'prevCertificate', label: 'Previous MTO License Certificate', description: 'Your current/expiring certificate', required: true },
|
||||
{ key: 'taxPaper', label: 'Tax Paper', description: 'Current tax clearance document', required: true },
|
||||
{ key: 'payroll', label: 'Employee Payroll', description: 'Payroll evidence for employees', required: true },
|
||||
{ key: 'updatedEmployeeList', label: 'Updated Employee List', description: 'Current list of employees', required: true },
|
||||
{ key: 'updatedInsurance', label: 'Updated Insurance Certificate', description: 'Current insurance certificate', required: true },
|
||||
{ key: 'customsBondRenewal', label: 'Customs Bond Renewal', description: 'Required only if the customs bond has been renewed', required: false },
|
||||
{ key: 'terminalLeaseRenewal', label: 'Terminal Lease / Rent Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||
{ key: 'truckRentalRenewal', label: 'Truck / Car Rental Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||
{ key: 'equipmentRentalRenewal', label: 'Equipment Rental Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||
{ key: 'branchOfficeContract', label: 'Branch Office Contract', description: 'Required only if the previous contract has expired', required: false },
|
||||
{ key: 'complianceDeclaration', label: 'Compliance Declaration', description: 'Signed compliance declaration', required: true },
|
||||
{ key: 'paymentReceipt', label: 'Payment Receipt', description: 'Receipt of renewal service payment, if already paid', required: false },
|
||||
];
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MtoLicenseRenewalPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({ url: '/logistics-licenses/mto/renew', method: 'POST', body: {} }).unwrap();
|
||||
notify.success('Renewal request submitted successfully!');
|
||||
navigate('/mto-license');
|
||||
} catch {
|
||||
notify.error('Renewal submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Renew MTO License</Title>
|
||||
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
If your terminal, truck, equipment, or branch office contract has expired since your last submission, an updated contract is required.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||
<Stack gap="md">
|
||||
{RENEWAL_DOCS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Renewal Request
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCamera,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Company Information' },
|
||||
{ label: 'Shipping Company Agreement' },
|
||||
{ label: 'Bank Letter' },
|
||||
{ label: 'Vehicle, Office & Terminal' },
|
||||
{ label: 'Employees' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.2M ETB)', description: 'Bank confirmation letter showing minimum capital', required: true, icon: IconFileDescription },
|
||||
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', description: 'Agreement document with the shipping company', required: true, icon: IconFileDescription },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', description: 'Vehicle libre copy if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', description: 'Office title deed if owned, or rental agreement if rented', required: true, icon: IconId },
|
||||
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', description: 'Terminal agreement if rented, or title deed if owned', required: true, icon: IconId },
|
||||
{ key: 'bookingClerkProfile', label: 'Booking Clerk Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||
{ key: 'bookingClerkExp', label: 'Booking Clerk Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||
{ key: 'canvasserProfile', label: 'Canvasser Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||
{ key: 'canvasserExp', label: 'Canvasser Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||
{ key: 'adminProfile', label: 'Administrative Staff Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||
{ key: 'adminExp', label: 'Administrative Staff Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||
{ key: 'ceoProfile', label: 'CEO/General Manager Profile', description: 'CV/profile and work experience evidence', required: true, icon: IconShieldCheck },
|
||||
{ key: 'ceoExp', label: 'CEO/General Manager Work Experience Evidence', description: 'Evidence of relevant work experience', required: true, icon: IconFileDescription },
|
||||
{ key: 'commercialReg', label: 'Commercial Registration Certificate', description: 'Company commercial registration certificate', required: true, icon: IconId },
|
||||
{ key: 'businessLicense', label: 'Business License', description: 'Valid business license', required: true, icon: IconId },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', description: 'Taxpayer Identification Number certificate', required: true, icon: IconId },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', description: 'Recent passport-size photo for certificate printing', required: true, icon: IconCamera, accept: 'image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
export function ShippingAgentLicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Step 0 — Company Information (email/phone auto-fetched from account, not shown here)
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tradeName, setTradeName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [commercialRegNumber, setCommercialRegNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [businessAddress, setBusinessAddress] = useState('');
|
||||
const [officeAddress, setOfficeAddress] = useState('');
|
||||
const [applicantType, setApplicantType] = useState<string | null>(null);
|
||||
const [ownershipType, setOwnershipType] = useState<string | null>(null);
|
||||
|
||||
// Step 1 — Shipping Company Agreement
|
||||
const [shippingCompanyName, setShippingCompanyName] = useState('');
|
||||
const [agreementRefNumber, setAgreementRefNumber] = useState('');
|
||||
const [agreementStartDate, setAgreementStartDate] = useState('');
|
||||
const [agreementEndDate, setAgreementEndDate] = useState('');
|
||||
|
||||
// Step 2 — Bank Letter
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [accountHolderName, setAccountHolderName] = useState('');
|
||||
const [capitalAmount, setCapitalAmount] = useState<string | number>('');
|
||||
|
||||
// Step 3 — Vehicle, Office & Terminal
|
||||
const [vehicleOwnership, setVehicleOwnership] = useState<string | null>(null);
|
||||
const [plateNumber, setPlateNumber] = useState('');
|
||||
const [officeOwnership, setOfficeOwnership] = useState<string | null>(null);
|
||||
const [terminalName, setTerminalName] = useState('');
|
||||
const [terminalOwnership, setTerminalOwnership] = useState<string | null>(null);
|
||||
|
||||
// Step 4 — Employees
|
||||
const [bookingClerkName, setBookingClerkName] = useState('');
|
||||
const [canvasserName, setCanvasserName] = useState('');
|
||||
const [adminStaffName, setAdminStaffName] = useState('');
|
||||
const [ceoName, setCeoName] = useState('');
|
||||
|
||||
// Step 5 — Documents
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!companyName.trim() && !!tradeName.trim() && !!tinNumber.trim() &&
|
||||
!!commercialRegNumber.trim() && !!businessLicenseNumber.trim() &&
|
||||
!!businessAddress.trim() && !!officeAddress.trim() && !!applicantType && !!ownershipType
|
||||
);
|
||||
if (active === 1) return (
|
||||
!!shippingCompanyName.trim() && !!agreementRefNumber.trim() &&
|
||||
!!agreementStartDate && !!agreementEndDate
|
||||
);
|
||||
if (active === 2) return !!bankName.trim() && !!accountHolderName.trim() && !!capitalAmount && Number(capitalAmount) >= 1200000;
|
||||
if (active === 3) return !!vehicleOwnership && !!plateNumber.trim() && !!officeOwnership && !!terminalName.trim() && !!terminalOwnership;
|
||||
if (active === 4) return !!bookingClerkName.trim() && !!canvasserName.trim() && !!adminStaffName.trim() && !!ceoName.trim();
|
||||
if (active === 5) return DOC_SLOTS.every((s) => !s.required || !!files[s.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-licenses/shipping-agent',
|
||||
method: 'POST',
|
||||
body: {
|
||||
companyName, tradeName, tinNumber, commercialRegNumber, businessLicenseNumber,
|
||||
businessAddress, officeAddress, applicantType, ownershipType,
|
||||
shippingCompanyName, agreementRefNumber, agreementStartDate, agreementEndDate,
|
||||
bankName, accountHolderName, capitalAmount,
|
||||
vehicleOwnership, plateNumber, officeOwnership, terminalName, terminalOwnership,
|
||||
bookingClerkName, canvasserName, adminStaffName, ceoName,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Shipping Agent License application submitted successfully!');
|
||||
navigate('/shipping-agent-license');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Shipping Agent License Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Company Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Organization Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Trade Name" required value={tradeName} onChange={(e) => setTradeName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Commercial Registration Number" required value={commercialRegNumber} onChange={(e) => setCommercialRegNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License Number" required value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<Select label="Applicant Type" required data={['Private Company', 'Sole Proprietorship', 'Public Enterprise']} value={applicantType} onChange={setApplicantType} />
|
||||
<Select label="Ownership Type" required data={['Local', 'Foreign', 'Joint Venture']} value={ownershipType} onChange={setOwnershipType} />
|
||||
<TextInput label="Business Address" required value={businessAddress} onChange={(e) => setBusinessAddress(e.currentTarget.value)} />
|
||||
<TextInput label="Office Address" required value={officeAddress} onChange={(e) => setOfficeAddress(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Provide the details of your agreement with the shipping company you represent.
|
||||
</Alert>
|
||||
<SectionHead title="Shipping Company Agreement" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Shipping Company Name" required value={shippingCompanyName} onChange={(e) => setShippingCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="Agreement Reference Number" required value={agreementRefNumber} onChange={(e) => setAgreementRefNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Agreement Start Date" type="date" required value={agreementStartDate} onChange={(e) => setAgreementStartDate(e.currentTarget.value)} />
|
||||
<TextInput label="Agreement End Date" type="date" required value={agreementEndDate} onChange={(e) => setAgreementEndDate(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Bank letter must show a minimum capital of 1,200,000 ETB.
|
||||
</Alert>
|
||||
<SectionHead title="Bank Letter / Capital Evidence" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||
<TextInput label="Account Holder Name" required value={accountHolderName} onChange={(e) => setAccountHolderName(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Capital Amount (ETB)"
|
||||
required
|
||||
min={0}
|
||||
value={capitalAmount}
|
||||
onChange={setCapitalAmount}
|
||||
error={capitalAmount && Number(capitalAmount) < 1200000 ? 'Must be at least 1,200,000 ETB' : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Vehicle Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Vehicle Ownership Type" required data={['Owned', 'Rented']} value={vehicleOwnership} onChange={setVehicleOwnership} />
|
||||
<TextInput label="Plate Number" required value={plateNumber} onChange={(e) => setPlateNumber(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Office Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select label="Office Ownership Type" required data={['Owned', 'Rented']} value={officeOwnership} onChange={setOfficeOwnership} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Terminal Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Terminal Name / Location" required value={terminalName} onChange={(e) => setTerminalName(e.currentTarget.value)} />
|
||||
<Select label="Terminal Ownership Type" required data={['Owned', 'Rented']} value={terminalOwnership} onChange={setTerminalOwnership} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Four employee roles are required: Booking Clerk, Canvasser, Administrative Staff, and CEO/General Manager.
|
||||
</Alert>
|
||||
<SectionHead title="Employee Profiles" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Booking Clerk Name" required value={bookingClerkName} onChange={(e) => setBookingClerkName(e.currentTarget.value)} />
|
||||
<TextInput label="Canvasser Name" required value={canvasserName} onChange={(e) => setCanvasserName(e.currentTarget.value)} />
|
||||
<TextInput label="Administrative Staff Name" required value={adminStaffName} onChange={(e) => setAdminStaffName(e.currentTarget.value)} />
|
||||
<TextInput label="CEO / General Manager Name" required value={ceoName} onChange={(e) => setCeoName(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 5 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file. The passport photo must be JPG or PNG.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 6 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting. If approved, you will be asked to pay 1000 ETB before certificate issuance.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="Trade Name" value={tradeName} />
|
||||
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||
<ReviewRow label="Commercial Reg. No." value={commercialRegNumber} />
|
||||
<ReviewRow label="Business Address" value={businessAddress} />
|
||||
<ReviewRow label="Office Address" value={officeAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Shipping Company Agreement</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Shipping Company Name" value={shippingCompanyName} />
|
||||
<ReviewRow label="Agreement Reference Number" value={agreementRefNumber} />
|
||||
<ReviewRow label="Agreement Start Date" value={agreementStartDate} />
|
||||
<ReviewRow label="Agreement End Date" value={agreementEndDate} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Bank Letter</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Bank Name" value={bankName} />
|
||||
<ReviewRow label="Capital Amount" value={`${Number(capitalAmount).toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Vehicle, Office, Terminal & Employees</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Vehicle Ownership" value={vehicleOwnership ?? ''} />
|
||||
<ReviewRow label="Office Ownership" value={officeOwnership ?? ''} />
|
||||
<ReviewRow label="Terminal Name" value={terminalName} />
|
||||
<ReviewRow label="Terminal Ownership" value={terminalOwnership ?? ''} />
|
||||
<ReviewRow label="Booking Clerk" value={bookingClerkName} />
|
||||
<ReviewRow label="Canvasser" value={canvasserName} />
|
||||
<ReviewRow label="Administrative Staff" value={adminStaffName} />
|
||||
<ReviewRow label="CEO / General Manager" value={ceoName} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{DOC_SLOTS.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/shipping-agent-license') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type LicenseStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Payment Pending' | 'Payment Confirmed' | 'Certificate Issued';
|
||||
|
||||
interface ShippingAgentApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Payment Pending': 'grape', 'Payment Confirmed': 'indigo', 'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShippingAgentLicensePage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<ShippingAgentApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<ShippingAgentApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/shipping-agent/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Shipping Agent License</Title>
|
||||
<Text fz="sm" c="dimmed">Apply for and manage your Shipping Agent License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShip size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for a Shipping Agent License</Text>
|
||||
<Text fz="sm" c="dimmed">Provide company, capital, vehicle, office, terminal, and employee information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Bank letter showing at least 1.2 million ETB" />
|
||||
<RequirementItem label="Shipping company agreement" />
|
||||
<RequirementItem label="Vehicle libre copy or vehicle rental agreement" />
|
||||
<RequirementItem label="Office title deed or office rental agreement" />
|
||||
<RequirementItem label="Terminal agreement or terminal title deed" />
|
||||
<RequirementItem label="Booking Clerk, Canvasser, Administrative Staff and CEO/General Manager profiles" />
|
||||
<RequirementItem label="Passport-size photo for certificate printing" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconShip size={18} />} onClick={() => navigate('/shipping-agent-license/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About Shipping Agent Licensing</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
After approval, a service payment of <strong>1000 ETB</strong> is required before certificate issuance.
|
||||
The certificate is valid for <strong>one year</strong> and must be renewed annually.
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Payment Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Payment Required">
|
||||
Your application has been approved. Please pay 1000 ETB to receive your certificate.
|
||||
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShip size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||
{ label: 'Approved', done: ['Approved', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Certificate Issued'].includes(application.status) },
|
||||
{ label: 'Certificate Issued', done: (['Certificate Issued'] as string[]).includes(application.status) },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{application.status === 'Certificate Issued' && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="md">Issued Certificate</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCircleCheck size={17} />} mb="md">
|
||||
Your Shipping Agent License certificate is ready. Valid until {application.expiryDate}.
|
||||
</Alert>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconCertificate size={20} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Shipping Agent License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { FileButton } from '@mantine/core';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
const RENEWAL_DOCS: DocSlot[] = [
|
||||
{ key: 'prevCertificate', label: 'Previous Shipping Agent Certificate', description: 'Your current/expiring certificate', required: true },
|
||||
{ key: 'taxClearance', label: 'Tax Clearance', description: 'Current tax clearance certificate', required: true },
|
||||
{ key: 'threeMonthClearance', label: 'Three-Month Clearance Evidence', description: 'Clearance evidence for the last three months', required: true },
|
||||
{ key: 'vehicleRenewal', label: 'Updated Vehicle Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
{ key: 'officeRenewal', label: 'Updated Office Rental Agreement', description: 'Required only if the previous agreement has expired', required: false },
|
||||
{ key: 'paymentReceipt', label: 'Renewal Service Payment Receipt (600 ETB)', description: 'Proof of payment for the renewal service fee', required: true },
|
||||
{ key: 'renewalDeclaration', label: 'Applicant Renewal Declaration', description: 'Signed declaration confirming renewal details', required: true },
|
||||
];
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<IconFileDescription size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShippingAgentLicenseRenewalPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(RENEWAL_DOCS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const canSubmit = RENEWAL_DOCS.every((s) => !s.required || !!files[s.key]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({ url: '/logistics-licenses/shipping-agent/renew', method: 'POST', body: {} }).unwrap();
|
||||
notify.success('Renewal request submitted successfully!');
|
||||
navigate('/shipping-agent-license');
|
||||
} catch {
|
||||
notify.error('Renewal submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShip size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Renew Shipping Agent License</Title>
|
||||
<Text fz="sm" c="dimmed">Submit renewal documents to extend your license by one year</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
A renewal service payment of 600 ETB is required. If your vehicle or office rental agreement has expired since your last submission, an updated agreement is required.
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="lg" mb="lg">Renewal Documents</Text>
|
||||
<Stack gap="md">
|
||||
{RENEWAL_DOCS.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={16} />}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit Renewal Request
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconReceipt,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Waiver Type & Applicant' },
|
||||
{ label: 'Import Product' },
|
||||
{ label: 'Port & Shipment' },
|
||||
{ label: 'Documents Upload' },
|
||||
{ label: 'Review & Submit' },
|
||||
];
|
||||
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
accept?: string;
|
||||
postWaiverOnly?: boolean;
|
||||
preWaiverOnly?: boolean;
|
||||
}
|
||||
|
||||
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap" style={{ overflowX: 'auto' }}>
|
||||
{STEPS.map((step, i) => {
|
||||
const isDone = completed.includes(i);
|
||||
const isCurrent = active === i;
|
||||
return (
|
||||
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
||||
<Box
|
||||
style={{
|
||||
width: rem(40),
|
||||
height: rem(40),
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: isDone
|
||||
? 'var(--mantine-color-blue-8)'
|
||||
: isCurrent
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-1)',
|
||||
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDone ? <IconCheck size={18} color="white" stroke={2.5} /> : (
|
||||
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>{i + 1}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="xs" fw={isCurrent ? 700 : 400} c={isCurrent ? 'blue.7' : 'dimmed'} style={{ whiteSpace: 'nowrap' }}>
|
||||
{isDone ? `${step.label} ✓` : step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box style={{
|
||||
flex: 1, height: rem(2), minWidth: rem(24),
|
||||
backgroundColor: isDone ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-gray-2)',
|
||||
marginBottom: rem(22),
|
||||
}} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<Divider mt="md" mb="xs" />
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({ slot, file, onFile }: { slot: DocSlot; file: File | null; onFile: (f: File | null) => void }) {
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const SlotIcon = slot.icon;
|
||||
return (
|
||||
<Card withBorder radius="md" p="md" style={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: file ? 'var(--mantine-color-teal-5)' : 'var(--mantine-color-default-border)',
|
||||
}}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(44), height: rem(44), borderRadius: rem(8),
|
||||
background: 'var(--mantine-color-blue-light)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<SlotIcon size={22} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{slot.label}{slot.required && <Text span c="red" ml={3}>*</Text>}</Text>
|
||||
<Text fz="xs" c="dimmed">{slot.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{file ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { onFile(null); resetRef.current?.(); }}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={onFile} accept={slot.accept ?? 'application/pdf,image/jpeg,image/png'}>
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'importCertificate', label: 'Import Certificate', description: 'Import certificate for the shipment', required: true, icon: IconId },
|
||||
{ key: 'invoice', label: 'Invoice for Imported Products', description: 'Commercial invoice for the goods', required: true, icon: IconFileDescription },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', description: 'TIN certificate / TIN number evidence', required: true, icon: IconId },
|
||||
{ key: 'declaration', label: 'Applicant Declaration', description: 'Signed applicant declaration', required: true, icon: IconFileDescription },
|
||||
{ key: 'supportingLetter', label: 'Supporting / Request Letter', description: 'Optional supporting letter', required: false, icon: IconFileDescription },
|
||||
{ key: 'billOfLading', label: 'Bill of Lading', description: 'Shipment bill of lading', required: false, icon: IconFileDescription },
|
||||
{ key: 'proformaInvoice', label: 'Proforma / Commercial Invoice', description: 'Required for Pre-Waiver', required: true, icon: IconFileDescription, preWaiverOnly: true },
|
||||
{ key: 'shipmentSchedule', label: 'Shipment Schedule / Booking Evidence', description: 'Optional vessel booking evidence', required: false, icon: IconFileDescription, preWaiverOnly: true },
|
||||
{ key: 'arrivalNotice', label: 'Arrival Notice / Port Arrival Evidence', description: 'Required for Post-Waiver', required: true, icon: IconFileDescription, postWaiverOnly: true },
|
||||
{ key: 'penaltyReceipt', label: 'Penalty Payment Receipt', description: 'Upload after penalty payment is made', required: false, icon: IconReceipt, postWaiverOnly: true },
|
||||
];
|
||||
|
||||
export function WaiverApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [waiverType, setWaiverType] = useState<string | null>(null);
|
||||
const [cargoArrivalStatus, setCargoArrivalStatus] = useState<string | null>(null);
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [tinNumber, setTinNumber] = useState('');
|
||||
const [importCertNumber, setImportCertNumber] = useState('');
|
||||
const [businessLicenseNumber, setBusinessLicenseNumber] = useState('');
|
||||
const [applicantAddress, setApplicantAddress] = useState('');
|
||||
const [reasonNonEsl, setReasonNonEsl] = useState('');
|
||||
const [bankName, setBankName] = useState('');
|
||||
const [bankBranch, setBankBranch] = useState('');
|
||||
const [letterRecipient, setLetterRecipient] = useState('');
|
||||
const [letterLanguage, setLetterLanguage] = useState<string | null>('English');
|
||||
|
||||
const [productName, setProductName] = useState('');
|
||||
const [productDescription, setProductDescription] = useState('');
|
||||
const [quantity, setQuantity] = useState<string | number>('');
|
||||
const [unitOfMeasurement, setUnitOfMeasurement] = useState('');
|
||||
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||
const [invoiceDate, setInvoiceDate] = useState('');
|
||||
const [invoiceAmount, setInvoiceAmount] = useState<string | number>('');
|
||||
const [countryOfOrigin, setCountryOfOrigin] = useState('');
|
||||
const [supplierName, setSupplierName] = useState('');
|
||||
|
||||
const [portOfLoading, setPortOfLoading] = useState('');
|
||||
const [portOfDischarge, setPortOfDischarge] = useState('');
|
||||
const [fromPortName, setFromPortName] = useState('');
|
||||
const [toPortName, setToPortName] = useState('');
|
||||
const [vesselName, setVesselName] = useState('');
|
||||
const [carrierName, setCarrierName] = useState('');
|
||||
const [billOfLadingNumber, setBillOfLadingNumber] = useState('');
|
||||
const [estimatedArrivalDate, setEstimatedArrivalDate] = useState('');
|
||||
const [actualArrivalDate, setActualArrivalDate] = useState('');
|
||||
|
||||
const isPreWaiver = waiverType === 'Pre-Waiver';
|
||||
const isPostWaiver = waiverType === 'Post-Waiver';
|
||||
|
||||
const [files, setFiles] = useState<Record<string, File | null>>(
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const setFile = (key: string) => (f: File | null) => setFiles((prev) => ({ ...prev, [key]: f }));
|
||||
|
||||
const visibleDocSlots = DOC_SLOTS.filter((s) => {
|
||||
if (s.preWaiverOnly) return isPreWaiver;
|
||||
if (s.postWaiverOnly) return isPostWaiver;
|
||||
return true;
|
||||
});
|
||||
|
||||
const canNext = () => {
|
||||
if (active === 0) return (
|
||||
!!waiverType && !!cargoArrivalStatus &&
|
||||
(!isPreWaiver || cargoArrivalStatus === 'Not Arrived at Port') &&
|
||||
(!isPostWaiver || cargoArrivalStatus === 'Arrived at Port') &&
|
||||
!!companyName.trim() && !!tinNumber.trim() && !!importCertNumber.trim() &&
|
||||
!!applicantAddress.trim() && !!reasonNonEsl.trim() && !!bankName.trim() && !!letterRecipient.trim()
|
||||
);
|
||||
if (active === 1) return (
|
||||
!!productName.trim() && !!productDescription.trim() && !!quantity && !!unitOfMeasurement.trim() &&
|
||||
!!invoiceNumber.trim() && !!invoiceDate && !!invoiceAmount
|
||||
);
|
||||
if (active === 2) return (
|
||||
!!portOfLoading.trim() && !!portOfDischarge.trim() && !!fromPortName.trim() && !!toPortName.trim() &&
|
||||
!!vesselName.trim() && !!carrierName.trim() &&
|
||||
(!isPreWaiver || !!estimatedArrivalDate) &&
|
||||
(!isPostWaiver || !!actualArrivalDate)
|
||||
);
|
||||
if (active === 3) return visibleDocSlots.every((s) => !s.required || !!files[s.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setCompleted((prev) => prev.includes(active) ? prev : [...prev, active]);
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
const prev = () => setActive((c) => c - 1);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/logistics-waivers',
|
||||
method: 'POST',
|
||||
body: {
|
||||
waiverType, cargoArrivalStatus, companyName, tinNumber, importCertNumber,
|
||||
businessLicenseNumber, applicantAddress, reasonNonEsl, bankName, bankBranch,
|
||||
letterRecipient, letterLanguage,
|
||||
productName, productDescription, quantity, unitOfMeasurement,
|
||||
invoiceNumber, invoiceDate, invoiceAmount, countryOfOrigin, supplierName,
|
||||
portOfLoading, portOfDischarge, fromPortName, toPortName, vesselName, carrierName,
|
||||
billOfLadingNumber, estimatedArrivalDate, actualArrivalDate,
|
||||
},
|
||||
}).unwrap();
|
||||
notify.success('Waiver application submitted successfully!');
|
||||
navigate('/waiver');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/waiver')}>
|
||||
Back
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Title order={3}>Waiver Application</Title>
|
||||
<Text fz="sm" c="dimmed">Step {active + 1} of {STEPS.length} — {STEPS[active].label}</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="lg">{STEPS[active].label}</Text>
|
||||
<Badge variant="light" color="blue" radius="md">Step {active + 1} of {STEPS.length}</Badge>
|
||||
</Group>
|
||||
|
||||
{active === 0 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Your account email and phone number will be used automatically — no need to re-enter them here.
|
||||
</Alert>
|
||||
<SectionHead title="Waiver Type" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="Waiver Type"
|
||||
required
|
||||
data={['Pre-Waiver', 'Post-Waiver']}
|
||||
value={waiverType}
|
||||
onChange={(v) => { setWaiverType(v); setCargoArrivalStatus(null); }}
|
||||
/>
|
||||
<Select
|
||||
label="Cargo Arrival Status"
|
||||
required
|
||||
data={['Not Arrived at Port', 'Arrived at Port']}
|
||||
value={cargoArrivalStatus}
|
||||
onChange={setCargoArrivalStatus}
|
||||
disabled={!waiverType}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{waiverType && cargoArrivalStatus && (
|
||||
(isPreWaiver && cargoArrivalStatus !== 'Not Arrived at Port') ||
|
||||
(isPostWaiver && cargoArrivalStatus !== 'Arrived at Port')
|
||||
) && (
|
||||
<Alert color="red" icon={<IconInfoCircle size={16} />}>
|
||||
Pre-Waiver requires cargo not yet arrived at port; Post-Waiver requires cargo already arrived at port.
|
||||
</Alert>
|
||||
)}
|
||||
{isPostWaiver && (
|
||||
<Alert color="grape" icon={<IconInfoCircle size={16} />}>
|
||||
Post-Waiver requires a penalty payment and is issued only once per import/cargo case.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SectionHead title="Applicant & Company Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Company / Importer Name" required value={companyName} onChange={(e) => setCompanyName(e.currentTarget.value)} />
|
||||
<TextInput label="TIN Number" required value={tinNumber} onChange={(e) => setTinNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Importer Certificate Number" required value={importCertNumber} onChange={(e) => setImportCertNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Business License Number" value={businessLicenseNumber} onChange={(e) => setBusinessLicenseNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Applicant Address" required value={applicantAddress} onChange={(e) => setApplicantAddress(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<SectionHead title="Waiver Request" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Textarea label="Reason for Using Non-ESL Vessel" required value={reasonNonEsl} onChange={(e) => setReasonNonEsl(e.currentTarget.value)} autosize minRows={2} />
|
||||
<Select label="Requested Letter Language" data={['English', 'Amharic']} value={letterLanguage} onChange={setLetterLanguage} />
|
||||
<TextInput label="Bank Name" required value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
|
||||
<TextInput label="Bank Branch" value={bankBranch} onChange={(e) => setBankBranch(e.currentTarget.value)} />
|
||||
<TextInput label="Letter Recipient" required placeholder="e.g. Bank Manager" value={letterRecipient} onChange={(e) => setLetterRecipient(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Product / Commodity" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Product / Commodity Name" required value={productName} onChange={(e) => setProductName(e.currentTarget.value)} />
|
||||
<TextInput label="Product Description" required value={productDescription} onChange={(e) => setProductDescription(e.currentTarget.value)} />
|
||||
<NumberInput label="Quantity" required min={0} value={quantity} onChange={setQuantity} />
|
||||
<TextInput label="Unit of Measurement" required value={unitOfMeasurement} onChange={(e) => setUnitOfMeasurement(e.currentTarget.value)} />
|
||||
<TextInput label="Country of Origin" value={countryOfOrigin} onChange={(e) => setCountryOfOrigin(e.currentTarget.value)} />
|
||||
<TextInput label="Supplier / Exporter Name" value={supplierName} onChange={(e) => setSupplierName(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Invoice Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Invoice Number" required value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.currentTarget.value)} />
|
||||
<TextInput type="date" label="Invoice Date" required value={invoiceDate} onChange={(e) => setInvoiceDate(e.currentTarget.value)} />
|
||||
<NumberInput label="Invoice Amount" required min={0} value={invoiceAmount} onChange={setInvoiceAmount} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 2 && (
|
||||
<Stack gap="md">
|
||||
<SectionHead title="Port Information" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Port of Loading" required value={portOfLoading} onChange={(e) => setPortOfLoading(e.currentTarget.value)} />
|
||||
<TextInput label="Port of Discharge / Destination Port" required value={portOfDischarge} onChange={(e) => setPortOfDischarge(e.currentTarget.value)} />
|
||||
<TextInput label="From Port Name" required value={fromPortName} onChange={(e) => setFromPortName(e.currentTarget.value)} />
|
||||
<TextInput label="To Port Name" required value={toPortName} onChange={(e) => setToPortName(e.currentTarget.value)} />
|
||||
</SimpleGrid>
|
||||
<SectionHead title="Vessel & Shipment" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput label="Vessel Name" required value={vesselName} onChange={(e) => setVesselName(e.currentTarget.value)} />
|
||||
<TextInput label="Shipping Line / Carrier Name" required value={carrierName} onChange={(e) => setCarrierName(e.currentTarget.value)} />
|
||||
<TextInput label="Bill of Lading Number" value={billOfLadingNumber} onChange={(e) => setBillOfLadingNumber(e.currentTarget.value)} />
|
||||
{isPreWaiver && (
|
||||
<TextInput type="date" label="Estimated Arrival Date" required value={estimatedArrivalDate} onChange={(e) => setEstimatedArrivalDate(e.currentTarget.value)} />
|
||||
)}
|
||||
{isPostWaiver && (
|
||||
<TextInput type="date" label="Actual Arrival Date" required value={actualArrivalDate} onChange={(e) => setActualArrivalDate(e.currentTarget.value)} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 3 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Upload clear scans or photos. Accepted formats: PDF, JPG, PNG. Max 5MB per file.
|
||||
{isPostWaiver && ' Post-Waiver requires Bill of Lading and arrival evidence; penalty receipt can be added after payment.'}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{visibleDocSlots.map((slot) => (
|
||||
<DocCard key={slot.key} slot={slot} file={files[slot.key]} onFile={setFile(slot.key)} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{active === 4 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Please review all information before submitting.
|
||||
{isPostWaiver && ' If approved, you will be asked to pay a penalty before the waiver letter is generated.'}
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Waiver Type & Applicant</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Waiver Type" value={waiverType ?? ''} />
|
||||
<ReviewRow label="Cargo Arrival Status" value={cargoArrivalStatus ?? ''} />
|
||||
<ReviewRow label="Company Name" value={companyName} />
|
||||
<ReviewRow label="TIN Number" value={tinNumber} />
|
||||
<ReviewRow label="Import Certificate Number" value={importCertNumber} />
|
||||
<ReviewRow label="Bank Name" value={bankName} />
|
||||
<ReviewRow label="Letter Recipient" value={letterRecipient} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Import Product</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="Product Name" value={productName} />
|
||||
<ReviewRow label="Quantity" value={`${quantity} ${unitOfMeasurement}`} />
|
||||
<ReviewRow label="Invoice Number" value={invoiceNumber} />
|
||||
<ReviewRow label="Invoice Amount" value={invoiceAmount ? `${Number(invoiceAmount).toLocaleString()}` : ''} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Port & Shipment</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<ReviewRow label="From Port" value={fromPortName} />
|
||||
<ReviewRow label="To Port" value={toPortName} />
|
||||
<ReviewRow label="Vessel Name" value={vesselName} />
|
||||
<ReviewRow label="Carrier Name" value={carrierName} />
|
||||
{isPreWaiver && <ReviewRow label="Estimated Arrival Date" value={estimatedArrivalDate} />}
|
||||
{isPostWaiver && <ReviewRow label="Actual Arrival Date" value={actualArrivalDate} />}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Documents</Text>
|
||||
<Stack gap={6}>
|
||||
{visibleDocSlots.map((slot) => (
|
||||
<Group key={slot.key} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={files[slot.key] ? 'teal' : 'gray'} variant={files[slot.key] ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={files[slot.key] ? undefined : 'dimmed'}>
|
||||
{slot.label} {!files[slot.key] && slot.required ? '(missing)' : files[slot.key] ? `— ${files[slot.key]!.name}` : '(not uploaded)'}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} onClick={active === 0 ? () => navigate('/waiver') : prev}>
|
||||
{active === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button rightSection={<IconArrowRight size={16} />} disabled={!canNext()} onClick={next}>Next</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconShip size={16} />} loading={submitting} onClick={handleSubmit}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
219
apps/portal/src/app/features/waiver/pages/WaiverPage.tsx
Normal file
219
apps/portal/src/app/features/waiver/pages/WaiverPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconFileCertificate,
|
||||
IconInfoCircle,
|
||||
IconShieldOff,
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
type WaiverStatus =
|
||||
| 'Submitted' | 'Under Review' | 'Under Evaluation' | 'Approved'
|
||||
| 'Resubmit Required' | 'Rejected' | 'Penalty Payment Pending' | 'Payment Confirmed' | 'Letter Generated' | 'Completed';
|
||||
|
||||
interface WaiverApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
waiverType: 'Pre-Waiver' | 'Post-Waiver';
|
||||
status: WaiverStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
remarks: string;
|
||||
penaltyAmount: number | null;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'blue', 'Under Review': 'yellow', 'Under Evaluation': 'yellow',
|
||||
Approved: 'teal', 'Resubmit Required': 'orange', Rejected: 'red',
|
||||
'Penalty Payment Pending': 'grape', 'Payment Confirmed': 'indigo',
|
||||
'Letter Generated': 'green', Completed: 'green',
|
||||
};
|
||||
|
||||
function RequirementItem({ label }: { label: string }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color="blue" variant="light"><IconCheck size={12} /></ThemeIcon>
|
||||
<Text fz="sm">{label}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function WaiverPage() {
|
||||
const navigate = useNavigate();
|
||||
const [application, setApplication] = useState<WaiverApplication | null>(null);
|
||||
const [fetchTrigger] = useApiMutation<WaiverApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-waivers/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApplication(data))
|
||||
.catch(() => {/* no application yet */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light"><IconShieldOff size={24} /></ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Maritime Logistics Waiver</Title>
|
||||
<Text fz="sm" c="dimmed">Request authorization to use a non-ESL vessel for importing goods</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{!application && (
|
||||
<>
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="md" mb="lg" wrap="nowrap">
|
||||
<ThemeIcon size={52} radius="xl" color="blue" variant="light"><IconShieldOff size={28} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Apply for a Waiver</Text>
|
||||
<Text fz="sm" c="dimmed">Request a Pre-Waiver (cargo not yet arrived) or Post-Waiver (cargo already arrived)</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<Text fw={600} fz="sm" mb="xs">Requirements</Text>
|
||||
<Stack gap={6} mb="xl">
|
||||
<RequirementItem label="Import certificate" />
|
||||
<RequirementItem label="Invoice for imported products" />
|
||||
<RequirementItem label="TIN certificate / TIN number evidence" />
|
||||
<RequirementItem label="Applicant declaration" />
|
||||
<RequirementItem label="Bill of Lading and arrival evidence (Post-Waiver)" />
|
||||
<RequirementItem label="Penalty payment receipt (Post-Waiver, after approval)" />
|
||||
</Stack>
|
||||
<Button size="md" leftSection={<IconShieldOff size={18} />} onClick={() => navigate('/waiver/apply')}>
|
||||
Start Application
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb={4}>
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-blue-7)" />
|
||||
<Text fw={600} fz="sm" c="blue.7">About the Waiver</Text>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">
|
||||
A waiver lets you use a non-ESL vessel to transport imported goods into Ethiopia. Once approved
|
||||
(and, for Post-Waiver, once any penalty payment is confirmed), EMA issues an official waiver letter
|
||||
addressed to your bank for import clearance. <strong>Post-Waiver is only issued once per cargo case.</strong>
|
||||
</Text>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
|
||||
{application && (
|
||||
<>
|
||||
{application.status === 'Resubmit Required' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="orange" title="Resubmission Required">
|
||||
{application.remarks || 'Please correct the requested information and resubmit.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Rejected' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="red" title="Application Rejected">
|
||||
{application.remarks || 'Your application was rejected.'}
|
||||
</Alert>
|
||||
)}
|
||||
{application.status === 'Penalty Payment Pending' && (
|
||||
<Alert icon={<IconAlertCircle size={17} />} color="grape" title="Penalty Payment Required">
|
||||
Your Post-Waiver has been approved. Please pay the penalty of {application.penaltyAmount?.toLocaleString() ?? '—'} ETB
|
||||
to proceed with waiver letter generation.
|
||||
<Button size="xs" variant="white" color="grape" mt="xs">Pay Now</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={40} radius="md" color="blue" variant="light"><IconShieldOff size={22} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{application.companyName}</Text>
|
||||
<Text fz="xs" c="dimmed">{application.id} · {application.waiverType}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[application.status] ?? 'gray'} size="lg" variant="light">{application.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{application.remarks && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} mb={4}>Officer Remarks</Text>
|
||||
<Text fz="sm">{application.remarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{application.status !== 'Completed' && application.status !== 'Letter Generated' && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconClockHour4 size={16} />
|
||||
<Text fw={600} fz="sm">Application Status</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Submitted', done: true },
|
||||
{ label: 'Under Evaluation', done: application.status !== 'Submitted' },
|
||||
{ label: 'Approved', done: ['Approved', 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed'].includes(application.status) },
|
||||
...(application.waiverType === 'Post-Waiver'
|
||||
? [{ label: 'Payment Confirmed', done: ['Payment Confirmed', 'Letter Generated', 'Completed'].includes(application.status) }]
|
||||
: []),
|
||||
{ label: 'Letter Generated', done: (['Letter Generated', 'Completed'] as string[]).includes(application.status) },
|
||||
].map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={step.done ? 'teal' : 'gray'} variant={step.done ? 'filled' : 'light'}>
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'}>{step.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{(application.status === 'Letter Generated' || application.status === 'Completed') && (
|
||||
<div>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconFileCertificate size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="md">Waiver Letter</Text>
|
||||
</Group>
|
||||
<Alert color="teal" icon={<IconCheck size={17} />} mb="md">
|
||||
Your {application.waiverType} letter has been generated and is ready for bank submission.
|
||||
</Alert>
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconFileCertificate size={20} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">EMA Waiver Letter (Bank Copy)</Text>
|
||||
<Text fz="xs" c="dimmed">Official letter addressed to your bank</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={14} />} fullWidth>
|
||||
Download Waiver Letter
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export const am: Translations = {
|
||||
vesselRegistration: 'የመርከብ ምዝገባ',
|
||||
vesselRegistrationDashboard: 'የመርከብ ምዝገባ ዳሽቦርድ',
|
||||
ownershipTransfer: 'የባለቤትነት ዝውውር',
|
||||
logisticsDashboard: 'የሎጂስቲክስ ፈቃዶች ዳሽቦርድ',
|
||||
freightForwarderLicense: 'የጭነት አስተላላፊ ፈቃድ',
|
||||
shippingAgentLicense: 'የመርከብ ወኪል ፈቃድ',
|
||||
combinedLicense: 'የተቀናጀ ፈቃድ',
|
||||
jointInvestmentLicense: 'የጋራ ኢንቨስትመንት ፈቃድ',
|
||||
mtoLicense: 'የMTO ፈቃድ',
|
||||
waiver: 'ነፃ ፈቃድ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
seafarerRegistry: 'የመርከበኞች ምዝገባ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user