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