mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,255 +1,21 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconChartBar,
|
||||
IconDownload,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconTrendingUp,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
const KPI = [
|
||||
{ label: 'Total Seafarers', value: 1284, delta: '+12 this month', color: 'blue', icon: IconUsers },
|
||||
{ label: 'Seaman Books Issued', value: 1102, delta: '85.8% of total', color: 'teal', icon: IconBook2 },
|
||||
{ label: 'BTC Issued', value: 1098, delta: '85.5% of total', color: 'teal', icon: IconCertificate },
|
||||
{ label: 'BSID Issued', value: 874, delta: '68.1% of total', color: 'violet', icon: IconId },
|
||||
{ label: 'CoC / CoP Issued', value: 342, delta: '26.6% of total', color: 'orange', icon: IconShieldCheck },
|
||||
{ label: 'Medical Expiring (90d)',value: 41, delta: 'Action required', color: 'red', icon: IconHeart },
|
||||
];
|
||||
|
||||
const MONTHLY_APPS = [
|
||||
{ month: 'Jan', seamanBook: 18, coc: 4 },
|
||||
{ month: 'Feb', seamanBook: 22, coc: 6 },
|
||||
{ month: 'Mar', seamanBook: 28, coc: 9 },
|
||||
{ month: 'Apr', seamanBook: 31, coc: 12 },
|
||||
{ month: 'May', seamanBook: 26, coc: 8 },
|
||||
{ month: 'Jun', seamanBook: 35, coc: 14 },
|
||||
];
|
||||
|
||||
const CERT_DISTRIBUTION = [
|
||||
{ label: 'Seaman Book', count: 1102, pct: 86, color: 'blue' },
|
||||
{ label: 'Basic Training Cert', count: 1098, pct: 85, color: 'teal' },
|
||||
{ label: 'BSID', count: 874, pct: 68, color: 'violet' },
|
||||
{ label: 'CoC / CoP', count: 342, pct: 27, color: 'orange' },
|
||||
];
|
||||
|
||||
const REVENUE = [
|
||||
{ cert: 'Seaman Book', apps: 1102, feePerApp: 700, revenue: 771400 },
|
||||
{ cert: 'BTC', apps: 1098, feePerApp: 400, revenue: 439200 },
|
||||
{ cert: 'BSID', apps: 874, feePerApp: 250, revenue: 218500 },
|
||||
{ cert: 'CoC / CoP', apps: 342, feePerApp: 1400, revenue: 478800 },
|
||||
];
|
||||
const TOTAL_REVENUE = REVENUE.reduce((s, r) => s + r.revenue, 0);
|
||||
|
||||
const EXPIRY_WATCH = [
|
||||
{ cert: 'Seaman Book', expiring30: 8, expiring90: 23, total: 1102 },
|
||||
{ cert: 'BTC', expiring30: 6, expiring90: 19, total: 1098 },
|
||||
{ cert: 'Medical Cert', expiring30: 12, expiring90: 41, total: 1284 },
|
||||
{ cert: 'CoC / CoP', expiring30: 4, expiring90: 17, total: 342 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
function KpiCard({ label, value, delta, color, icon: Icon }: typeof KPI[0]) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<ThemeIcon variant="light" color={color} size={44} radius="md"><Icon size={22} stroke={1.6} /></ThemeIcon>
|
||||
<Text fz="xl" fw={800}>{value.toLocaleString()}</Text>
|
||||
</Group>
|
||||
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={2}>{delta}</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple bar chart using divs
|
||||
function BarChart() {
|
||||
const max = Math.max(...MONTHLY_APPS.map((m) => m.seamanBook + m.coc));
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{MONTHLY_APPS.map((m) => (
|
||||
<Group key={m.month} gap="sm" align="center" wrap="nowrap">
|
||||
<Text fz="xs" c="dimmed" w={28} style={{ flexShrink: 0 }}>{m.month}</Text>
|
||||
<div style={{ flex: 1, position: 'relative', height: 22 }}>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${(m.seamanBook / max) * 100}%`, background: 'var(--mantine-color-blue-4)', borderRadius: 4 }} />
|
||||
<div style={{ position: 'absolute', left: `${(m.seamanBook / max) * 100}%`, top: 0, height: '100%', width: `${(m.coc / max) * 100}%`, background: 'var(--mantine-color-orange-4)', borderRadius: '0 4px 4px 0' }} />
|
||||
</div>
|
||||
<Text fz="xs" fw={600} w={40} style={{ flexShrink: 0 }}>{m.seamanBook + m.coc}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap="md" mt="xs">
|
||||
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-blue-4)' }} /><Text fz="xs" c="dimmed">Seaman Book</Text></Group>
|
||||
<Group gap={6}><div style={{ width: 12, height: 12, borderRadius: 2, background: 'var(--mantine-color-orange-4)' }} /><Text fz="xs" c="dimmed">CoC / CoP</Text></Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered invented figures/records that were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function AnalyticsPage() {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Analytics & Reports</Title>
|
||||
<Text fz="sm" c="dimmed">Overview of seafarer registrations, certificate issuance, and revenue — mock data for demonstration</Text>
|
||||
</div>
|
||||
<Button variant="default" size="sm" leftSection={<IconDownload size={14} />}>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 6 }} spacing="sm">
|
||||
{KPI.map((k) => <KpiCard key={k.label} {...k} />)}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{/* Monthly applications bar chart */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={28} radius="md" color="blue" variant="light"><IconChartBar size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Monthly Applications (2025)</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<BarChart />
|
||||
</Paper>
|
||||
|
||||
{/* Certificate distribution */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="xs" mb="lg">
|
||||
<ThemeIcon size={28} radius="md" color="teal" variant="light"><IconTrendingUp size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Certificate Distribution</Text>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{CERT_DISTRIBUTION.map((c) => (
|
||||
<div key={c.label}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap="xs">
|
||||
<Badge color={c.color} variant="light" size="sm">{c.label}</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" fw={600}>{c.count.toLocaleString()}</Text>
|
||||
<Text fz="xs" c="dimmed">{c.pct}%</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Progress value={c.pct} color={c.color} radius="xl" size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Revenue breakdown */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="sm">Revenue Breakdown by Certificate Type</Text>
|
||||
<Badge size="lg" variant="light" color="blue">Total: ETB {TOTAL_REVENUE.toLocaleString()}</Badge>
|
||||
</Group>
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate Type', 'Applications', 'Fee per App', 'Total Revenue', '% of Revenue'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{REVENUE.map((r) => (
|
||||
<Table.Tr key={r.cert}>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.cert}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.apps.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">ETB {r.feePerApp.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={700} c="blue">ETB {r.revenue.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Progress value={(r.revenue / TOTAL_REVENUE) * 100} size="sm" radius="xl" style={{ flex: 1, minWidth: 60 }} />
|
||||
<Text fz="xs" c="dimmed">{((r.revenue / TOTAL_REVENUE) * 100).toFixed(1)}%</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td><Text fz="sm" fw={700}>Total</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={700}>{REVENUE.reduce((s, r) => s + r.apps, 0).toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td />
|
||||
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {TOTAL_REVENUE.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Expiry watchlist */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="xs" mb="md">
|
||||
<ThemeIcon size={28} radius="md" color="red" variant="light"><IconHeart size={15} /></ThemeIcon>
|
||||
<Text fw={700} fz="sm">Expiry Watchlist</Text>
|
||||
</Group>
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate', 'Total Issued', 'Expiring in 30 days', 'Expiring in 90 days', '% at Risk'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{EXPIRY_WATCH.map((e) => (
|
||||
<Table.Tr key={e.cert}>
|
||||
<Table.Td><Text fz="sm" fw={600}>{e.cert}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{e.total.toLocaleString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={e.expiring30 > 5 ? 'red' : 'orange'} variant="light" size="sm">{e.expiring30}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={e.expiring90 > 20 ? 'orange' : 'yellow'} variant="light" size="sm">{e.expiring90}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Progress
|
||||
value={(e.expiring90 / e.total) * 100}
|
||||
color={(e.expiring90 / e.total) > 0.1 ? 'red' : 'orange'}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text fz="xs" c="dimmed">{((e.expiring90 / e.total) * 100).toFixed(1)}%</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Analytics"
|
||||
description="Reporting is not connected to the backend yet. The Logistics overview shows real licence figures in the meantime."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsPage;
|
||||
|
||||
@@ -1,446 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
const FEES = [
|
||||
{ group: 'Seaman Book', label: 'Application Fee', amount: 500 },
|
||||
{ group: 'Seaman Book', label: 'Document Verification Fee', amount: 200 },
|
||||
{ group: 'Basic Training Certificate (BTC)', label: 'Application Fee', amount: 300 },
|
||||
{ group: 'Basic Training Certificate (BTC)', label: 'Document Verification Fee',amount: 100 },
|
||||
{ group: 'BSID', label: 'Application Fee', amount: 100 },
|
||||
];
|
||||
const TOTAL = FEES.reduce((s, f) => s + f.amount, 0);
|
||||
|
||||
const MOCK_APPLICATION = {
|
||||
id: 'SB-APP-2025-001',
|
||||
submittedAt: '2025-05-10',
|
||||
status: 'pending_review' as const,
|
||||
applicant: {
|
||||
fullName: 'Abebe Girma Tadesse',
|
||||
dob: '1990-04-15',
|
||||
nationality: 'Ethiopian',
|
||||
idNumber: 'ID-ETH-2024-001',
|
||||
phone: '+251 91 234 5678',
|
||||
email: 'abebe.girma@email.com',
|
||||
address: 'Bole, Addis Ababa, Ethiopia',
|
||||
height: '178 cm',
|
||||
bloodType: 'O+',
|
||||
hairColor: 'Black',
|
||||
eyeColor: 'Dark Brown',
|
||||
seafarerNumber: 'SEAF-2024-00142',
|
||||
},
|
||||
bst: [
|
||||
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-15', expiryDate: '2029-01-15', fileName: 'pst_cert.pdf', fileType: 'pdf' },
|
||||
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime Training School', issueDate: '2024-01-16', expiryDate: '2029-01-16', fileName: 'fpff_cert.pdf', fileType: 'pdf' },
|
||||
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: '', fileName: 'efa_cert.jpg', fileType: 'image' },
|
||||
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: '', fileName: 'pssr_cert.jpg', fileType: 'image' },
|
||||
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: '', fileName: 'shpt_cert.jpg', fileType: 'image' },
|
||||
],
|
||||
medical: {
|
||||
certNumber: 'MC-2024-009',
|
||||
issuer: 'EMA Medical Centre — Addis Ababa',
|
||||
issueDate: '2024-03-20',
|
||||
expiryDate: '2026-03-20',
|
||||
fileName: 'medical_cert.pdf',
|
||||
fileType: 'pdf',
|
||||
},
|
||||
payment: {
|
||||
method: 'CBE Bank Transfer',
|
||||
ref: 'CBE-TXN-20240510-001',
|
||||
date: '2025-05-10',
|
||||
status: 'confirmed' as 'confirmed' | 'pending' | 'rejected',
|
||||
receiptFileName: 'payment_receipt.pdf',
|
||||
receiptFileType: 'pdf',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending_review: 'yellow', approved: 'teal', correction_required: 'orange', rejected: 'red',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending_review: 'Pending Review', approved: 'Approved', correction_required: 'Correction Required', rejected: 'Rejected',
|
||||
};
|
||||
const PAYMENT_COLOR = { confirmed: 'teal', pending: 'yellow', rejected: 'red' } as const;
|
||||
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline document viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
function DocViewer({ fileName, fileType, label }: { fileName: string; fileType: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const url = getDocUrl(fileName);
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={fileType === 'pdf' ? 'red' : 'blue'}>{fileType.toUpperCase()}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{fileType === 'pdf'
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function ApplicationReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const app = MOCK_APPLICATION;
|
||||
|
||||
const [status, setStatus] = useState<string>(app.status);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
const [noteAction, setNoteAction] = useState<'correction' | 'reject' | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const performAction = async (action: string, label: string, newStatus: string) => {
|
||||
setActionLoading(action);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setStatus(newStatus);
|
||||
setActionLoading(null);
|
||||
notify.success(`Application ${label}`);
|
||||
if (noteModalOpen) setNoteModalOpen(false);
|
||||
};
|
||||
|
||||
const openNote = (action: 'correction' | 'reject') => {
|
||||
setNoteAction(action);
|
||||
setNote('');
|
||||
setNoteModalOpen(true);
|
||||
};
|
||||
|
||||
// Group fees by category for display
|
||||
const feeGroups = FEES.reduce<Record<string, typeof FEES>>((acc, f) => {
|
||||
(acc[f.group] = acc[f.group] || []).push(f);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/seaman-book-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submittedAt}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[status] ?? 'gray'}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{status === 'pending_review' && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" color="orange" variant="light" leftSection={<IconAlertTriangle size={15} />} onClick={() => openNote('correction')}>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button size="sm" color="red" variant="light" leftSection={<IconX size={15} />} onClick={() => openNote('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="sm" color="teal" leftSection={<IconCheck size={15} />} onClick={() => performAction('approve', 'approved', 'approved')} loading={actionLoading === 'approve'}>
|
||||
Approve Application
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{status !== 'pending_review' && (
|
||||
<Alert variant="light" color={STATUS_COLOR[status] ?? 'gray'}
|
||||
icon={status === 'approved' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}>
|
||||
This application has been <strong>{STATUS_LABEL[status] ?? status}</strong>.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="profile" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant Profile</Tabs.Tab>
|
||||
<Tabs.Tab value="bst" leftSection={<IconShieldCheck size={16} />}>BST Certificates</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconHeart size={16} />}>Medical</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="issuance" leftSection={<IconCertificate size={16} />}>Issuance</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant Profile ─────────────────────────────────────── */}
|
||||
<Tabs.Panel value="profile">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.applicant.fullName}</Text>
|
||||
<Text fz="sm" c="dimmed">Seafarer No: {app.applicant.seafarerNumber}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Date of Birth" value={app.applicant.dob} />
|
||||
<InfoRow label="Nationality" value={app.applicant.nationality} />
|
||||
<InfoRow label="National ID" value={app.applicant.idNumber} />
|
||||
<InfoRow label="Phone" value={app.applicant.phone} />
|
||||
<InfoRow label="Email" value={app.applicant.email} />
|
||||
<InfoRow label="Address" value={app.applicant.address} />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Physical Characteristics</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label="Height" value={app.applicant.height} />
|
||||
<InfoRow label="Blood Type" value={app.applicant.bloodType} />
|
||||
<InfoRow label="Hair Color" value={app.applicant.hairColor} />
|
||||
<InfoRow label="Eye Color" value={app.applicant.eyeColor} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── BST Certificates ──────────────────────────────────────── */}
|
||||
<Tabs.Panel value="bst">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Verify all 5 BST training certificates. PST and FPFF have 5-year renewal — check expiry dates are valid.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{app.bst.map((cert) => (
|
||||
<Paper key={cert.key} withBorder radius="lg" p="md">
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
|
||||
<IconShieldCheck size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{cert.short}</Text>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap={4} mb="sm">
|
||||
<InfoRow label="Certificate No." value={cert.certNumber} />
|
||||
<InfoRow label="Issuer" value={cert.issuer} />
|
||||
<InfoRow label="Issue Date" value={cert.issueDate} />
|
||||
{cert.expiryDate && <InfoRow label="Expiry Date" value={cert.expiryDate} />}
|
||||
</Stack>
|
||||
<DocViewer fileName={cert.fileName} fileType={cert.fileType} label={`${cert.short} Certificate`} />
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Medical ───────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="medical">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="pink" radius="lg"><IconHeart size={22} stroke={1.5} /></ThemeIcon>
|
||||
<Text fw={700} fz="lg">Medical Certificate</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Certificate No." value={app.medical.certNumber} />
|
||||
<InfoRow label="Issuing Centre" value={app.medical.issuer} />
|
||||
<InfoRow label="Issue Date" value={app.medical.issueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.medical.expiryDate} />
|
||||
</SimpleGrid>
|
||||
<Box style={{ maxWidth: rem(420) }}>
|
||||
<DocViewer fileName={app.medical.fileName} fileType={app.medical.fileType} label="Medical Certificate" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment</Text>
|
||||
<Badge size="sm" variant="light" color={PAYMENT_COLOR[app.payment.status]} mt={2}>
|
||||
{app.payment.status.charAt(0).toUpperCase() + app.payment.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Fee breakdown grouped */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">Fee Breakdown</Text>
|
||||
{Object.entries(feeGroups).map(([group, fees], gi) => (
|
||||
<div key={group}>
|
||||
{gi > 0 && <Divider my="sm" />}
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="blue.7" mb={6}>{group}</Text>
|
||||
{fees.map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB {TOTAL.toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Payment Method" value={app.payment.method} />
|
||||
<InfoRow label="Transaction Reference" value={app.payment.ref} />
|
||||
<InfoRow label="Payment Date" value={app.payment.date} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ maxWidth: rem(420) }}>
|
||||
<DocViewer fileName={app.payment.receiptFileName} fileType={app.payment.receiptFileType} label="Payment Receipt" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Issuance ──────────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="issuance">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color={status === 'approved' ? 'teal' : 'yellow'} icon={<IconInfoCircle size={17} />}>
|
||||
{status === 'approved'
|
||||
? 'Application approved. The documents below will be generated and dispatched to the applicant.'
|
||||
: 'Approve the application to trigger document issuance.'}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{[
|
||||
{ icon: IconBook2, color: 'blue', label: 'Seaman Book', desc: 'Official EMA seafarer identity book', validity: '5 years' },
|
||||
{ icon: IconCertificate,color: 'teal', label: 'Basic Training Certificate (BTC)',desc: 'EMA-issued BTC for all 5 BST courses', validity: '5 years' },
|
||||
{ icon: IconShieldCheck,color: 'violet',label: 'BSID Card', desc: 'Biometric Seafarer ID Card', validity: '5 years' },
|
||||
].map(({ icon: Icon, color, label, desc, validity }) => (
|
||||
<Card key={label} withBorder radius="lg" p="md"
|
||||
style={{ borderColor: status === 'approved' ? `var(--mantine-color-${color}-3)` : 'var(--mantine-color-gray-2)' }}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={status === 'approved' ? color : 'gray'} radius="lg">
|
||||
<Icon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{label}</Text>
|
||||
<Badge size="xs" variant="light" color={status === 'approved' ? 'teal' : 'yellow'} mt={2}>
|
||||
{status === 'approved' ? 'Ready to Issue' : 'Pending Approval'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="xs">{desc}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Validity</Text>
|
||||
<Text fz="xs" fw={500}>{validity}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Note modal */}
|
||||
<Modal
|
||||
opened={noteModalOpen}
|
||||
onClose={() => setNoteModalOpen(false)}
|
||||
title={<Group gap="xs"><IconBook2 size={18} /><Text fw={700}>{noteAction === 'correction' ? 'Request Correction' : 'Reject Application'}</Text></Group>}
|
||||
size="md" radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{noteAction === 'correction' ? 'Describe what the applicant needs to correct or resubmit.' : 'State the reason for rejection. The applicant will be notified.'}
|
||||
</Text>
|
||||
<Textarea label="Note to Applicant" placeholder="Enter your note..." rows={4} value={note} onChange={(e) => setNote(e.currentTarget.value)} required />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setNoteModalOpen(false)}>Cancel</Button>
|
||||
<Button color={noteAction === 'correction' ? 'orange' : 'red'} disabled={!note.trim()} loading={actionLoading === noteAction}
|
||||
onClick={() => performAction(noteAction!, noteAction === 'correction' ? 'sent back for correction' : 'rejected', noteAction === 'correction' ? 'correction_required' : 'rejected')}>
|
||||
{noteAction === 'correction' ? 'Send Back' : 'Reject'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Application review"
|
||||
description="This generic review screen is superseded by Licence Applications, which is connected to real data."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApplicationReviewPage;
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDeviceFloppy,
|
||||
IconEye,
|
||||
IconPlus,
|
||||
IconRosetteDiscountCheck,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useArchiveLicenseTemplateMutation,
|
||||
useCreateLicenseTemplateMutation,
|
||||
useDeleteLicenseTemplateMutation,
|
||||
useGetBuiltInTemplateQuery,
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
||||
|
||||
/** Same resolution the shared RTK Query baseQuery uses. */
|
||||
const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
PUBLISHED: 'teal',
|
||||
ARCHIVED: 'dark',
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the authority designs the certificate its licensees receive.
|
||||
*
|
||||
* The layout used to be a Handlebars file inside the deployed image, so any
|
||||
* change to the authority's own certificate needed a developer and a release.
|
||||
* Here it is data: staff author a version, preview the real PDF, and publish.
|
||||
* Publishing archives the incumbent, so exactly one design is live per licence
|
||||
* type and previously issued certificates keep the design they were made from.
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
const canPublish = can([PERMISSIONS.PUBLISH_TEMPLATE]);
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const [typeId, setTypeId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: templates = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
|
||||
const { data: variables = [] } = useGetTemplateVariablesQuery();
|
||||
const { data: builtIn } = useGetBuiltInTemplateQuery();
|
||||
|
||||
const [createTemplate, { isLoading: creating }] = useCreateLicenseTemplateMutation();
|
||||
const [updateTemplate, { isLoading: saving }] = useUpdateLicenseTemplateMutation();
|
||||
const [publishTemplate, { isLoading: publishing }] = usePublishLicenseTemplateMutation();
|
||||
const [archiveTemplate] = useArchiveLicenseTemplateMutation();
|
||||
const [deleteTemplate] = useDeleteLicenseTemplateMutation();
|
||||
const [updateValidity, { isLoading: savingValidity }] = useUpdateLicenseValidityMutation();
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [source, setSource] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [landscape, setLandscape] = useState(true);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
const [validityMonths, setValidityMonths] = useState<number>(12);
|
||||
|
||||
// Default to the first licence type so the page is never an empty shell.
|
||||
useEffect(() => {
|
||||
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
|
||||
}, [licenseTypes, typeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
|
||||
}, [selectedType]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => templates.find((tpl) => tpl.id === selectedId) ?? null,
|
||||
[templates, selectedId],
|
||||
);
|
||||
|
||||
// Pick the live design by default — that is the one staff usually want.
|
||||
useEffect(() => {
|
||||
if (!templates.length) {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedId && templates.some((tpl) => tpl.id === selectedId)) return;
|
||||
const published = templates.find((tpl) => tpl.status === 'PUBLISHED');
|
||||
setSelectedId((published ?? templates[0]).id);
|
||||
}, [templates, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
setSource(selected.hbsSource);
|
||||
setName(selected.name);
|
||||
setLandscape(selected.pageOptions?.landscape ?? true);
|
||||
}, [selected]);
|
||||
|
||||
const isPublished = selected?.status === 'PUBLISHED';
|
||||
const dirty =
|
||||
Boolean(selected) &&
|
||||
(source !== selected?.hbsSource ||
|
||||
name !== selected?.name ||
|
||||
landscape !== (selected?.pageOptions?.landscape ?? true));
|
||||
|
||||
async function run(action: () => Promise<unknown>, success: string) {
|
||||
try {
|
||||
await action();
|
||||
notifications.show({ color: 'teal', title: success, message: '' });
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts a placeholder where the caret is, rather than at the end. */
|
||||
function insertVariable(key: string) {
|
||||
const el = editorRef.current;
|
||||
const token = `{{${key}}}`;
|
||||
if (!el) {
|
||||
setSource((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? source.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
setSource(source.slice(0, start) + token + source.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor's current contents, not the saved row, so unsaved edits
|
||||
* are what you see. Opened as a blob so it never leaves a file behind.
|
||||
*/
|
||||
async function preview() {
|
||||
try {
|
||||
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
|
||||
// and calls the API directly — which means spelling out the base URL and
|
||||
// the bearer token that the shared baseQuery would normally attach.
|
||||
const token = authStorage.getToken();
|
||||
const response = await fetch(`${API_BASE_URL}/license-templates/preview`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hbsSource: source,
|
||||
licenseTypeId: typeId,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Give the new tab time to read it before revoking.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('designer.previewFailed', 'Could not render the preview'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<PageHeader
|
||||
title={t('designer.title', 'Certificate designer')}
|
||||
subtitle={t(
|
||||
'designer.subtitle',
|
||||
'Design the certificate issued to licence holders, and set how long it stays valid.',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Group align="flex-end" mb="md" gap="sm">
|
||||
<Select
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name?.en ?? type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
setTypeId(value);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
w={280}
|
||||
/>
|
||||
|
||||
{/* Validity lives beside the design because it is the other half of
|
||||
what a certificate promises. */}
|
||||
<NumberInput
|
||||
label={t('designer.validityYears', 'Valid for (years)')}
|
||||
description={t('designer.validityHint', 'Applied when a licence is issued')}
|
||||
value={Number((validityMonths / 12).toFixed(2))}
|
||||
onChange={(value) => setValidityMonths(Math.round(Number(value || 0) * 12))}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
decimalScale={1}
|
||||
w={190}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
canEdit
|
||||
? t('designer.saveValidity', 'Save validity')
|
||||
: t('designer.noPermission', 'You do not have permission')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={savingValidity}
|
||||
disabled={!canEdit || !typeId || validityMonths === selectedType?.validityMonths}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => updateValidity({ id: typeId as string, validityMonths }).unwrap(),
|
||||
t('designer.validitySaved', 'Validity updated'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.saveValidity', 'Save validity')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
disabled={!canEdit || !typeId}
|
||||
onClick={() => {
|
||||
setNewName(
|
||||
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
|
||||
);
|
||||
setNewOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('designer.newVersion', 'New version')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
title={t('designer.loadFailed', 'Could not load the designs')}
|
||||
description={extractErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
icon={IconAlertCircle}
|
||||
/>
|
||||
) : !isLoading && templates.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('designer.empty', 'No design yet for this licence type')}
|
||||
description={t(
|
||||
'designer.emptyBody',
|
||||
'Certificates currently use the built-in layout. Create a version to take control of it.',
|
||||
)}
|
||||
action={
|
||||
canEdit
|
||||
? {
|
||||
label: t('designer.newVersion', 'New version'),
|
||||
onClick: () => {
|
||||
setNewName(`${selectedType?.name?.en ?? 'Certificate'} v1`);
|
||||
setNewOpen(true);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||
{/* Versions */}
|
||||
<Stack gap="xs" w={240} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.versions', 'Versions')}
|
||||
</Text>
|
||||
{templates.map((tpl) => (
|
||||
<Card
|
||||
key={tpl.id}
|
||||
withBorder
|
||||
padding="xs"
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor:
|
||||
tpl.id === selectedId ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{tpl.version}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[tpl.status]}>
|
||||
{tpl.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Editor */}
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Switch
|
||||
label={t('designer.landscape', 'Landscape')}
|
||||
checked={landscape}
|
||||
onChange={(e) => setLandscape(e.currentTarget.checked)}
|
||||
disabled={!canEdit || isPublished}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isPublished && (
|
||||
<Paper withBorder p="xs" bg="var(--mantine-color-teal-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.publishedLocked',
|
||||
'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={editorRef}
|
||||
label={t('designer.source', 'Template (Handlebars + HTML)')}
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.currentTarget.value)}
|
||||
disabled={!canEdit || isPublished}
|
||||
autosize
|
||||
minRows={18}
|
||||
maxRows={30}
|
||||
styles={{ input: { fontFamily: 'monospace', fontSize: 12 } }}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={16} />}
|
||||
onClick={preview}
|
||||
disabled={!source.trim()}
|
||||
>
|
||||
{t('designer.preview', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
loading={saving}
|
||||
disabled={!canEdit || isPublished || !dirty}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
updateTemplate({
|
||||
id: selected!.id,
|
||||
name,
|
||||
hbsSource: source,
|
||||
pageOptions: { format: 'A4', landscape, printBackground: true },
|
||||
}).unwrap(),
|
||||
t('designer.saved', 'Draft saved'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.save', 'Save draft')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
!canPublish
|
||||
? t('designer.noPublishPermission', 'You cannot publish designs')
|
||||
: dirty
|
||||
? t('designer.saveFirst', 'Save your changes first')
|
||||
: t('designer.publishHint', 'Makes this the live certificate design')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={16} />}
|
||||
loading={publishing}
|
||||
disabled={!canPublish || isPublished || dirty || !selected}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => publishTemplate(selected!.id).unwrap(),
|
||||
t('designer.published', 'Design published'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.publish', 'Publish')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div style={{ flex: 1 }} />
|
||||
{selected && isPublished && canPublish && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() =>
|
||||
run(
|
||||
() => archiveTemplate(selected.id).unwrap(),
|
||||
t('designer.archived', 'Design withdrawn'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('designer.archive', 'Withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
{selected && !isPublished && canEdit && (
|
||||
<Tooltip label={t('designer.delete', 'Delete draft')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('designer.delete', 'Delete draft')}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => deleteTemplate(selected.id).unwrap(),
|
||||
t('designer.deleted', 'Draft deleted'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Placeholders */}
|
||||
<Stack gap="xs" w={230} style={{ flexShrink: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('designer.variables', 'Placeholders')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('designer.variablesHint', 'Click to insert at the cursor.')}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={480} type="hover">
|
||||
<Stack gap={4}>
|
||||
{variables.map((variable) => (
|
||||
<Tooltip key={variable.key} label={variable.label} position="left">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
justify="flex-start"
|
||||
disabled={!canEdit || isPublished}
|
||||
onClick={() => insertVariable(variable.key)}
|
||||
>
|
||||
<Code fz={10}>{`{{${variable.key}}}`}</Code>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={newOpen}
|
||||
onClose={() => setNewOpen(false)}
|
||||
title={t('designer.newVersion', 'New version')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
withAsterisk
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.newHint',
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={creating}
|
||||
disabled={!newName.trim()}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
const created = await createTemplate({
|
||||
licenseTypeId: typeId as string,
|
||||
name: newName.trim(),
|
||||
hbsSource: templates.length ? undefined : builtIn?.hbsSource,
|
||||
}).unwrap();
|
||||
setSelectedId(created.id);
|
||||
setNewOpen(false);
|
||||
}, t('designer.created', 'Draft created'))
|
||||
}
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CertificateDesignerPage;
|
||||
@@ -1,338 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type CoCAppStatus =
|
||||
| 'Submitted'
|
||||
| 'Document Review'
|
||||
| 'TRB Inspection'
|
||||
| 'TRB Resubmit Required'
|
||||
| 'TRB Rejected'
|
||||
| 'Examination Scheduled'
|
||||
| 'Examination Passed'
|
||||
| 'Examination Failed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface CoCApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
certType: string;
|
||||
stcwRef: string;
|
||||
level: 'Support' | 'Operational' | 'Management';
|
||||
dept: string;
|
||||
submitted: string;
|
||||
paymentStatus: 'Verified' | 'Pending';
|
||||
status: CoCAppStatus;
|
||||
examDate: string | null;
|
||||
examVenue: string | null;
|
||||
seaServiceFile: string;
|
||||
trbFile: string;
|
||||
competencyDocs: { area: string; uploaded: boolean; filename: string }[];
|
||||
trbInspectionNotes: string;
|
||||
examNotes: string;
|
||||
officerRemarks: string;
|
||||
}
|
||||
|
||||
export const STATUS_COLOR: Record<CoCAppStatus, string> = {
|
||||
'Submitted': 'gray',
|
||||
'Document Review': 'blue',
|
||||
'TRB Inspection': 'yellow',
|
||||
'TRB Resubmit Required': 'orange',
|
||||
'TRB Rejected': 'red',
|
||||
'Examination Scheduled': 'indigo',
|
||||
'Examination Passed': 'teal',
|
||||
'Examination Failed': 'red',
|
||||
'Certificate Issued': 'teal',
|
||||
};
|
||||
|
||||
export const MOCK_COC_APPS: CoCApp[] = [
|
||||
{
|
||||
id: 'COC-APP-2025-001',
|
||||
seafarerId: 'SF-2024-0001',
|
||||
name: 'Abebe Girma',
|
||||
email: 'abebe.g@email.com',
|
||||
mobile: '+251 911 234 567',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Officer in Charge of a Navigational Watch (OICNW)',
|
||||
stcwRef: 'Reg. II/1 — Code A-II/1',
|
||||
level: 'Operational',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-03-10',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'TRB Inspection',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
seaServiceFile: 'abebe_sea_service.pdf',
|
||||
trbFile: 'abebe_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation at Operational Level', uploaded: true, filename: 'abebe_nav.pdf' },
|
||||
{ area: 'Cargo Handling & Stowage', uploaded: true, filename: 'abebe_cargo.pdf' },
|
||||
{ area: 'Control of Ship Operations', uploaded: true, filename: 'abebe_ops.pdf' },
|
||||
{ area: 'Radio Communication (GMDSS)', uploaded: true, filename: 'abebe_gmdss.pdf' },
|
||||
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'abebe_brm.pdf' },
|
||||
],
|
||||
trbInspectionNotes: '',
|
||||
examNotes: '',
|
||||
officerRemarks: '',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-002',
|
||||
seafarerId: 'SF-2024-0002',
|
||||
name: 'Sara Tadesse',
|
||||
email: 'sara.t@email.com',
|
||||
mobile: '+251 922 345 678',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Able Seafarer Deck (AB)',
|
||||
stcwRef: 'Reg. II/5 — Code A-II/5',
|
||||
level: 'Support',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-03-18',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Document Review',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
seaServiceFile: 'sara_sea_service.pdf',
|
||||
trbFile: '',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation', uploaded: true, filename: 'sara_nav.pdf' },
|
||||
{ area: 'Cargo Operations', uploaded: true, filename: 'sara_cargo.pdf' },
|
||||
{ area: 'Ship Operations', uploaded: false, filename: '' },
|
||||
{ area: 'Safety & Emergency', uploaded: true, filename: 'sara_safety.pdf' },
|
||||
],
|
||||
trbInspectionNotes: '',
|
||||
examNotes: '',
|
||||
officerRemarks: 'Missing Ship Operations certificate. Requested resubmission.',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-003',
|
||||
seafarerId: 'SF-2024-0003',
|
||||
name: 'Dawit Bekele',
|
||||
email: 'dawit.b@email.com',
|
||||
mobile: '+251 933 456 789',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Officer in Charge of an Engineering Watch (OICEW)',
|
||||
stcwRef: 'Reg. III/1 — Code A-III/1',
|
||||
level: 'Operational',
|
||||
dept: 'Engine',
|
||||
submitted: '2025-03-25',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Examination Scheduled',
|
||||
examDate: '2025-05-10',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
seaServiceFile: 'dawit_sea_service.pdf',
|
||||
trbFile: 'dawit_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Marine Engineering at Operational Level', uploaded: true, filename: 'dawit_eng.pdf' },
|
||||
{ area: 'Electrical & Control Systems', uploaded: true, filename: 'dawit_elec.pdf' },
|
||||
{ area: 'Maintenance & Repair', uploaded: true, filename: 'dawit_maint.pdf' },
|
||||
{ area: 'Controlling Ship Operations', uploaded: true, filename: 'dawit_ops.pdf' },
|
||||
{ area: 'Leadership & Teamwork', uploaded: true, filename: 'dawit_brm.pdf' },
|
||||
],
|
||||
trbInspectionNotes: 'TRB physically inspected on 2025-04-20. All competency entries signed off by approved assessor.',
|
||||
examNotes: '',
|
||||
officerRemarks: '',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-004',
|
||||
seafarerId: 'SF-2023-0088',
|
||||
name: 'Tekle Haile',
|
||||
email: 'tekle.h@email.com',
|
||||
mobile: '+251 944 567 890',
|
||||
nationality: 'Ethiopian',
|
||||
certType: 'Chief Mate — Ships 500–3,000 GT',
|
||||
stcwRef: 'Reg. II/2 — Code A-II/2',
|
||||
level: 'Management',
|
||||
dept: 'Deck',
|
||||
submitted: '2025-04-01',
|
||||
paymentStatus: 'Verified',
|
||||
status: 'Examination Passed',
|
||||
examDate: '2025-05-20',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
seaServiceFile: 'tekle_sea_service.pdf',
|
||||
trbFile: 'tekle_trb.pdf',
|
||||
competencyDocs: [
|
||||
{ area: 'Navigation at Management Level', uploaded: true, filename: 'tekle_nav.pdf' },
|
||||
{ area: 'Cargo Handling at Management Level', uploaded: true, filename: 'tekle_cargo.pdf' },
|
||||
{ area: 'Control of Ship Operations', uploaded: true, filename: 'tekle_ops.pdf' },
|
||||
{ area: 'Leadership & Management', uploaded: true, filename: 'tekle_lead.pdf' },
|
||||
],
|
||||
trbInspectionNotes: 'TRB inspected on 2025-04-28. All competency records complete and verified by EMA examiner.',
|
||||
examNotes: 'Oral examination conducted 2025-05-20. Candidate demonstrated full command of management-level navigation competencies. Result: PASS.',
|
||||
officerRemarks: '',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'All', 'Submitted', 'Document Review', 'TRB Inspection',
|
||||
'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled',
|
||||
'Examination Passed', 'Examination Failed', 'Certificate Issued',
|
||||
];
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function CoCQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
const filtered = MOCK_COC_APPS.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: MOCK_COC_APPS.length,
|
||||
trbPending: MOCK_COC_APPS.filter((a) => a.status === 'TRB Inspection' || a.status === 'TRB Resubmit Required').length,
|
||||
examSched: MOCK_COC_APPS.filter((a) => a.status === 'Examination Scheduled').length,
|
||||
issued: MOCK_COC_APPS.filter((a) => a.status === 'Certificate Issued' || a.status === 'Examination Passed').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>CoC / CoP Application Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review documents, inspect TRBs, schedule examinations and issue certificates</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconShieldCheck },
|
||||
{ label: 'TRB Inspection', value: stats.trbPending, color: 'yellow', icon: IconBook2 },
|
||||
{ label: 'Exam Scheduled', value: stats.examSched, color: 'indigo', icon: IconClock },
|
||||
{ label: 'Passed / Issued', value: stats.issued, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Application Queue</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v ?? 'All')}
|
||||
size="sm"
|
||||
style={{ width: rem(200) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconShieldCheck size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Certificate', 'Level', 'Docs', 'TRB', 'Payment', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => {
|
||||
const allDocs = app.competencyDocs.every((d) => d.uploaded);
|
||||
return (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" fw={500}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={160} lh={1.3}>{app.certType}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.level === 'Management' ? 'violet' : app.level === 'Operational' ? 'blue' : 'gray'}>
|
||||
{app.level}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={allDocs ? 'teal' : 'orange'}>
|
||||
{app.competencyDocs.filter(d => d.uploaded).length}/{app.competencyDocs.length}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{app.trbFile
|
||||
? <Badge size="xs" variant="light" color="teal"><IconCheck size={10} /> Submitted</Badge>
|
||||
: <Badge size="xs" variant="light" color="gray">N/A</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/coc-queue/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_COC_APPS.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="CoC / CoP queue"
|
||||
description="Certificate of Competency review is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoCQueuePage;
|
||||
|
||||
@@ -1,621 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconBook2,
|
||||
IconCalendar,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_COC_APPS, STATUS_COLOR } from './CoCQueuePage';
|
||||
import type { CoCApp, CoCAppStatus } from './CoCQueuePage';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo doc viewer (same pattern as ApplicationReviewPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
if (!fileName) return '';
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPdf = fileName.endsWith('.pdf');
|
||||
const url = getDocUrl(fileName);
|
||||
if (!fileName) return <Badge size="xs" color="red" variant="light">Not uploaded</Badge>;
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow progress bar
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEP_ORDER: CoCAppStatus[] = [
|
||||
'Submitted', 'Document Review', 'TRB Inspection',
|
||||
'Examination Scheduled', 'Examination Passed', 'Certificate Issued',
|
||||
];
|
||||
|
||||
function WorkflowBar({ status }: { status: CoCAppStatus }) {
|
||||
const activeIdx = STEP_ORDER.indexOf(status);
|
||||
const isBranch = ['TRB Rejected', 'TRB Resubmit Required', 'Examination Failed'].includes(status);
|
||||
const labels = ['Submitted', 'Doc Review', 'TRB Inspect', 'Exam Scheduled', 'Exam Passed', 'Issued'];
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" mb="md">
|
||||
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Application Progress</Text>
|
||||
<Group gap={0} align="flex-start" wrap="nowrap">
|
||||
{STEP_ORDER.map((s, i) => {
|
||||
const done = isBranch ? false : activeIdx > i;
|
||||
const current = !isBranch && activeIdx === i;
|
||||
return (
|
||||
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={2} align="center" style={{ minWidth: rem(52) }}>
|
||||
<Box style={{
|
||||
width: rem(30), height: rem(30), borderRadius: '50%',
|
||||
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{done
|
||||
? <IconCheck size={14} color="white" stroke={2.5} />
|
||||
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
|
||||
</Box>
|
||||
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{labels[i]}</Text>
|
||||
</Stack>
|
||||
{i < STEP_ORDER.length - 1 && (
|
||||
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
{isBranch && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
|
||||
<Text fz="xs" fw={600}>
|
||||
{status === 'TRB Resubmit Required' && 'TRB resubmission required — applicant notified.'}
|
||||
{status === 'TRB Rejected' && 'TRB rejected — applicant must submit a new application.'}
|
||||
{status === 'Examination Failed' && 'Examination failed — applicant must submit a new application.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action modal
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXAM_VENUES = [
|
||||
{ value: 'addis', label: 'EMA HQ — Addis Ababa' },
|
||||
{ value: 'regional', label: 'EMA Regional Office — Djibouti Liaison' },
|
||||
];
|
||||
|
||||
type ActionKey = 'advance_trb' | 'request_docs' | 'approve_trb' | 'resubmit_trb' | 'reject_trb' | 'exam_pass' | 'exam_fail' | 'issue_cert';
|
||||
|
||||
const ACTION_TO_STATUS: Record<ActionKey, CoCAppStatus> = {
|
||||
advance_trb: 'TRB Inspection',
|
||||
request_docs: 'Document Review',
|
||||
approve_trb: 'Examination Scheduled',
|
||||
resubmit_trb: 'TRB Resubmit Required',
|
||||
reject_trb: 'TRB Rejected',
|
||||
exam_pass: 'Examination Passed',
|
||||
exam_fail: 'Examination Failed',
|
||||
issue_cert: 'Certificate Issued',
|
||||
};
|
||||
|
||||
function getActions(status: CoCAppStatus): { value: ActionKey; label: string }[] {
|
||||
switch (status) {
|
||||
case 'Submitted':
|
||||
case 'Document Review':
|
||||
return [
|
||||
{ value: 'advance_trb', label: 'Documents OK — Move to TRB Inspection' },
|
||||
{ value: 'request_docs', label: 'Request Additional Documents' },
|
||||
];
|
||||
case 'TRB Inspection':
|
||||
case 'TRB Resubmit Required':
|
||||
return [
|
||||
{ value: 'approve_trb', label: 'TRB Approved — Schedule Examination' },
|
||||
{ value: 'resubmit_trb', label: 'Request TRB Resubmission' },
|
||||
{ value: 'reject_trb', label: 'Reject TRB (Close Application)' },
|
||||
];
|
||||
case 'Examination Scheduled':
|
||||
return [
|
||||
{ value: 'exam_pass', label: 'Record Result — Passed' },
|
||||
{ value: 'exam_fail', label: 'Record Result — Failed' },
|
||||
];
|
||||
case 'Examination Passed':
|
||||
return [{ value: 'issue_cert', label: 'Issue Certificate' }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ActionModal({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
app: CoCApp;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [examDate, setExamDate] = useState('');
|
||||
const [venue, setVenue] = useState<string | null>(null);
|
||||
|
||||
const actionOptions = getActions(app.status);
|
||||
const needsExam = action === 'approve_trb';
|
||||
const isDestructive = action === 'reject_trb' || action === 'exam_fail';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!action) return;
|
||||
if (needsExam && (!examDate || !venue)) {
|
||||
notify.error('Please set an examination date and venue.'); return;
|
||||
}
|
||||
const newStatus = ACTION_TO_STATUS[action as ActionKey];
|
||||
const venueLabel = EXAM_VENUES.find(v => v.value === venue)?.label ?? venue ?? undefined;
|
||||
onAction(newStatus, notes, examDate || undefined, venueLabel);
|
||||
setAction(null); setNotes(''); setExamDate(''); setVenue(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconShieldCheck size={17} /><Text fw={700}>Officer Action</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{actionOptions.length === 0 ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
<Text fz="sm" fw={600}>No further actions available for this status.</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Action"
|
||||
placeholder="Select an action…"
|
||||
data={actionOptions}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
size="sm"
|
||||
/>
|
||||
{needsExam && (
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Examination Date" type="date" value={examDate} onChange={(e) => setExamDate(e.currentTarget.value)} leftSection={<IconCalendar size={14} />} size="sm" required />
|
||||
<Select label="Venue" placeholder="Select venue" data={EXAM_VENUES} value={venue} onChange={setVenue} size="sm" required />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<Textarea
|
||||
label="Notes / Remarks (sent to applicant)"
|
||||
placeholder="TRB inspection findings, exam result details, rejection reason…"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
rows={3}
|
||||
size="sm"
|
||||
/>
|
||||
{isDestructive && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="xs" fw={600}>
|
||||
{action === 'reject_trb' && 'This will permanently close the application. The applicant must submit a new application.'}
|
||||
{action === 'exam_fail' && 'This will record an examination failure. The applicant must apply again to re-attempt.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
leftSection={<IconCheck size={14} />}
|
||||
disabled={!action}
|
||||
color={isDestructive ? 'red' : action === 'issue_cert' || action === 'approve_trb' || action === 'exam_pass' ? 'teal' : 'blue'}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main review page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function CoCReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const original = MOCK_COC_APPS.find((a) => a.id === id);
|
||||
|
||||
const [app, setApp] = useState<CoCApp | null>(original ?? null);
|
||||
const [actionModalOpen, setActionModalOpen] = useState(false);
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/coc-queue')}>Back to Queue</Button>
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAction = (newStatus: CoCAppStatus, notes: string, examDate?: string, examVenue?: string) => {
|
||||
const trbStatuses: CoCAppStatus[] = ['TRB Inspection', 'TRB Resubmit Required', 'TRB Rejected', 'Examination Scheduled'];
|
||||
const examStatuses: CoCAppStatus[] = ['Examination Passed', 'Examination Failed'];
|
||||
setApp((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
examDate: examDate ?? prev.examDate,
|
||||
examVenue: examVenue ?? prev.examVenue,
|
||||
trbInspectionNotes: trbStatuses.includes(newStatus) ? notes || prev.trbInspectionNotes : prev.trbInspectionNotes,
|
||||
examNotes: examStatuses.includes(newStatus) ? notes || prev.examNotes : prev.examNotes,
|
||||
officerRemarks: notes || prev.officerRemarks,
|
||||
};
|
||||
});
|
||||
const labels: Record<CoCAppStatus, string> = {
|
||||
'Submitted': 'moved to Submitted',
|
||||
'Document Review': 'moved to Document Review',
|
||||
'TRB Inspection': 'moved to TRB Inspection',
|
||||
'TRB Resubmit Required': 'TRB resubmission requested',
|
||||
'TRB Rejected': 'TRB rejected — application closed',
|
||||
'Examination Scheduled': `examination scheduled for ${examDate}`,
|
||||
'Examination Passed': 'examination result recorded — Passed',
|
||||
'Examination Failed': 'examination result recorded — Failed',
|
||||
'Certificate Issued': 'certificate issued',
|
||||
};
|
||||
notify.success(`Application ${labels[newStatus] ?? newStatus}.`);
|
||||
};
|
||||
|
||||
const allDocs = app.competencyDocs.every((d) => d.uploaded);
|
||||
const actionOptions = getActions(app.status);
|
||||
const isTerminal = ['TRB Rejected', 'Examination Failed', 'Certificate Issued'].includes(app.status);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/coc-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>CoC / CoP Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{!isTerminal && actionOptions.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" leftSection={<IconShieldCheck size={15} />} onClick={() => setActionModalOpen(true)}>
|
||||
Take Action
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isTerminal && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Certificate Issued' ? 'teal' : 'red'}
|
||||
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
|
||||
>
|
||||
<Text fz="sm" fw={600}>
|
||||
{app.status === 'Certificate Issued' && 'Certificate has been issued and dispatched to the seafarer.'}
|
||||
{app.status === 'TRB Rejected' && 'TRB has been rejected. This application is closed. Applicant must re-apply.'}
|
||||
{app.status === 'Examination Failed' && 'Examination failed. This application is closed. Applicant must re-apply.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<WorkflowBar status={app.status} />
|
||||
|
||||
<Tabs defaultValue="profile" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="profile" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
|
||||
<Tabs.Tab value="competency" leftSection={<IconFileDescription size={16} />}>Competency Docs</Tabs.Tab>
|
||||
<Tabs.Tab value="trb" leftSection={<IconBook2 size={16} />}>TRB Inspection</Tabs.Tab>
|
||||
<Tabs.Tab value="exam" leftSection={<IconCertificate size={16} />}>Examination</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="issuance" leftSection={<IconShieldCheck size={16} />}>Issuance</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant ─────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="profile">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.name}</Text>
|
||||
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="Email" value={app.email} />
|
||||
<InfoRow label="Mobile" value={app.mobile} />
|
||||
<InfoRow label="Nationality" value={app.nationality} />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Certificate Applied For</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Certificate" value={app.certType} />
|
||||
<InfoRow label="STCW Reference" value={app.stcwRef} />
|
||||
<InfoRow label="Level" value={app.level} />
|
||||
<InfoRow label="Department" value={app.dept} />
|
||||
<InfoRow label="Submitted" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Competency Documents ──────────────────────────────── */}
|
||||
<Tabs.Panel value="competency">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color={allDocs ? 'teal' : 'orange'} icon={allDocs ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}>
|
||||
{allDocs
|
||||
? `All ${app.competencyDocs.length} competency certificates uploaded.`
|
||||
: `${app.competencyDocs.filter(d => !d.uploaded).length} competency certificate(s) missing.`}
|
||||
</Alert>
|
||||
|
||||
{/* Sea service */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Sea Service Record</SectionLabel>
|
||||
<Box maw={360}>
|
||||
<DocViewer fileName={app.seaServiceFile} label="Sea Service Record / Discharge Book" />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Competency certs */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Competency Certificates ({app.competencyDocs.length} areas)</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{app.competencyDocs.map((doc) => (
|
||||
<div key={doc.area}>
|
||||
<Group gap="xs" mb="xs">
|
||||
{doc.uploaded
|
||||
? <ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>
|
||||
: <ThemeIcon size={18} radius="xl" color="red" variant="light"><IconX size={11} /></ThemeIcon>}
|
||||
<Text fz="xs" fw={700}>{doc.area}</Text>
|
||||
</Group>
|
||||
<DocViewer fileName={doc.filename} label={doc.area} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── TRB Inspection ────────────────────────────────────── */}
|
||||
<Tabs.Panel value="trb">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
|
||||
The Training Record Book (TRB) must be physically inspected at the EMA office.
|
||||
The applicant brings the original; record your inspection findings below after taking action.
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Submitted TRB</SectionLabel>
|
||||
{app.trbFile
|
||||
? <Box maw={360}><DocViewer fileName={app.trbFile} label="Training Record Book (TRB)" /></Box>
|
||||
: <Badge color="gray" variant="light">Not submitted (entry-level certificate — TRB not required)</Badge>}
|
||||
</Paper>
|
||||
{app.trbInspectionNotes && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>TRB Inspection Notes</SectionLabel>
|
||||
<Text fz="sm">{app.trbInspectionNotes}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
{!app.trbInspectionNotes && app.trbFile && (
|
||||
<Alert variant="light" color="blue" icon={<IconBook2 size={17} />}>
|
||||
TRB inspection has not yet been recorded. Use "Take Action" above to approve, request resubmission, or reject the TRB.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Examination ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="exam">
|
||||
<Stack gap="md">
|
||||
{app.examDate ? (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Scheduled Examination</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
|
||||
<InfoRow label="Examination Date" value={app.examDate} />
|
||||
<InfoRow label="Venue" value={app.examVenue ?? ''} />
|
||||
</SimpleGrid>
|
||||
{app.examNotes && (
|
||||
<>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Examination Result Notes</SectionLabel>
|
||||
<Text fz="sm">{app.examNotes}</Text>
|
||||
</>
|
||||
)}
|
||||
{['Examination Scheduled'].includes(app.status) && (
|
||||
<Alert variant="light" color="indigo" icon={<IconCalendar size={17} />} mt="md">
|
||||
Examination is scheduled. After the examination, use "Take Action" to record the result (Pass or Fail).
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={17} />}>
|
||||
No examination scheduled yet. Examination is assigned by an EMA officer after TRB approval.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment Status</Text>
|
||||
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">CoC / CoP Application Fee</Text>
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 800 },
|
||||
{ label: 'Document Verification Fee', amount: 300 },
|
||||
{ label: 'Examination Fee', amount: 500 },
|
||||
{ label: 'Certificate Issuance Fee', amount: 400 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB 2,000.00</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
|
||||
<InfoRow label="Transaction Reference" value="CBE-TXN-20250315-042" />
|
||||
<InfoRow label="Payment Date" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Issuance ──────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="issuance">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'}
|
||||
icon={app.status === 'Certificate Issued' ? <IconCircleCheck size={17} /> : <IconInfoCircle size={17} />}
|
||||
>
|
||||
{app.status === 'Certificate Issued'
|
||||
? 'Certificate has been issued. The seafarer has been notified.'
|
||||
: 'Certificate will be issued after the application is fully approved and examination passed.'}
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<SectionLabel>Certificate to be Issued</SectionLabel>
|
||||
<Card withBorder radius="lg" p="md" style={{ borderColor: app.status === 'Certificate Issued' ? 'var(--mantine-color-teal-3)' : 'var(--mantine-color-gray-2)' }}>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'gray'} radius="lg">
|
||||
<IconShieldCheck size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{app.certType}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
|
||||
<Badge size="xs" variant="light" color={app.status === 'Certificate Issued' ? 'teal' : 'yellow'} mt={4}>
|
||||
{app.status === 'Certificate Issued' ? 'Issued' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<InfoRow label="Validity" value="5 years" />
|
||||
<InfoRow label="Revalidation" value="Before expiry — STCW Reg I/11" />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ActionModal
|
||||
app={app}
|
||||
opened={actionModalOpen}
|
||||
onClose={() => setActionModalOpen(false)}
|
||||
onAction={handleAction}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="CoC / CoP review"
|
||||
description="Certificate of Competency review is not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoCReviewPage;
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShip,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type LicenseStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Payment Pending'
|
||||
| 'Payment Confirmed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface CombinedLicenseApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
commercialRegNumber: string;
|
||||
businessAddress: string;
|
||||
bankName: string;
|
||||
capitalAmount: number;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_COMBINED_APPLICATIONS: CombinedLicenseApplication[] = [
|
||||
{
|
||||
id: 'CFS-2024-001',
|
||||
companyName: 'Blue Nile Shipping & Forwarding PLC',
|
||||
tinNumber: 'TIN-0098231',
|
||||
commercialRegNumber: 'CR-902341',
|
||||
businessAddress: 'Addis Ababa, Bole Sub-city',
|
||||
bankName: 'Commercial Bank of Ethiopia',
|
||||
capitalAmount: 2200000,
|
||||
status: 'Under Evaluation',
|
||||
submittedDate: '2024-03-14',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: 'Terminal agreement and transit staff certificates under review.',
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'CFS-2024-002',
|
||||
companyName: 'Red Sea Gateway Logistics Ltd',
|
||||
tinNumber: 'TIN-0071122',
|
||||
commercialRegNumber: 'CR-813457',
|
||||
businessAddress: 'Dire Dawa',
|
||||
bankName: 'Dashen Bank',
|
||||
capitalAmount: 1650000,
|
||||
status: 'Submitted',
|
||||
submittedDate: '2024-04-05',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: '',
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'CFS-2023-017',
|
||||
companyName: 'Tana Maritime & Forwarding PLC',
|
||||
tinNumber: 'TIN-0045690',
|
||||
commercialRegNumber: 'CR-704128',
|
||||
businessAddress: 'Addis Ababa, Kirkos Sub-city',
|
||||
bankName: 'Awash Bank',
|
||||
capitalAmount: 2500000,
|
||||
status: 'Certificate Issued',
|
||||
submittedDate: '2023-10-12',
|
||||
approvalDate: '2023-11-08',
|
||||
expiryDate: '2024-11-08',
|
||||
remarks: 'All requirements verified. Certificate issued.',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
'Payment Pending': 'grape',
|
||||
'Payment Confirmed': 'indigo',
|
||||
'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: CombinedLicenseApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
|
||||
{ label: 'Business Address', value: app.businessAddress },
|
||||
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Shipping Company Agreement', ok: app.docsComplete },
|
||||
{ label: 'Bank Letter (≥ 1.5M ETB)', ok: app.docsComplete },
|
||||
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'Terminal Agreement / Title Deed', ok: app.docsComplete },
|
||||
{ label: '2 Qualified Transit Employees (ERB Certificates)', ok: app.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function CombinedLicenseQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<CombinedLicenseApplication[]>(MOCK_COMBINED_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<CombinedLicenseApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<CombinedLicenseApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/combined?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: LicenseStatus =
|
||||
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved — pending payment.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
|
||||
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/combined-license/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Combined Shipping Agent + Freight Forwarder License Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Combined Shipping Agent + Freight Forwarder License applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Bank Letter Amount</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/combined-license/${id}`)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const COMBINED_LICENSE_ICON = IconShip;
|
||||
@@ -1,306 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,447 +1,146 @@
|
||||
import { useState, type ElementType, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Paper,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Stack,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Grid,
|
||||
ThemeIcon,
|
||||
Badge,
|
||||
Avatar,
|
||||
SegmentedControl,
|
||||
Button,
|
||||
Box,
|
||||
UnstyledButton,
|
||||
Anchor,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import {
|
||||
IconUsers,
|
||||
IconUserCheck,
|
||||
IconUserPlus,
|
||||
IconClockHour4,
|
||||
IconShieldLock,
|
||||
IconMail,
|
||||
IconUpload,
|
||||
IconDownload,
|
||||
IconClipboardList,
|
||||
IconArrowUpRight,
|
||||
IconArrowDownRight,
|
||||
IconArrowRight,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Cell,
|
||||
PieChart,
|
||||
Pie,
|
||||
} from 'recharts';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* sample data — swap for live API results */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const REGISTRATIONS = [
|
||||
{ month: 'Nov', value: 320 },
|
||||
{ month: 'Dec', value: 410 },
|
||||
{ month: 'Jan', value: 380 },
|
||||
{ month: 'Feb', value: 520 },
|
||||
{ month: 'Mar', value: 470 },
|
||||
{ month: 'Apr', value: 610 },
|
||||
{ month: 'May', value: 560 },
|
||||
{ month: 'Jun', value: 720 },
|
||||
];
|
||||
|
||||
const ROLES = [
|
||||
{ name: 'Admin', value: 18, color: '#1d4ed8' },
|
||||
{ name: 'Staff', value: 42, color: '#3b82f6' },
|
||||
{ name: 'Viewer', value: 30, color: '#60a5fa' },
|
||||
{ name: 'Guest', value: 10, color: '#93c5fd' },
|
||||
];
|
||||
|
||||
function useKpis(t: (key: string) => string) {
|
||||
return [
|
||||
{ label: t('dashboard.kpis.totalUsers'), value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
|
||||
{ label: t('dashboard.kpis.activeUsers'), value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
|
||||
{ label: t('dashboard.kpis.newThisMonth'), value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
|
||||
{ label: t('dashboard.kpis.pendingApprovals'), value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
|
||||
];
|
||||
}
|
||||
|
||||
function useQuickLinks(t: (key: string) => string) {
|
||||
return [
|
||||
{ label: t('dashboard.quickLinks.addUser'), desc: t('dashboard.quickLinks.addUserDesc'), icon: IconUserPlus, color: 'blue' },
|
||||
{ label: t('dashboard.quickLinks.rolesAndPermissions'), desc: t('dashboard.quickLinks.rolesAndPermissionsDesc'), icon: IconShieldLock, color: 'indigo' },
|
||||
{ label: t('dashboard.quickLinks.inviteMembers'), desc: t('dashboard.quickLinks.inviteMembersDesc'), icon: IconMail, color: 'cyan' },
|
||||
{ label: t('dashboard.quickLinks.importUsers'), desc: t('dashboard.quickLinks.importUsersDesc'), icon: IconUpload, color: 'violet' },
|
||||
{ label: t('dashboard.quickLinks.exportData'), desc: t('dashboard.quickLinks.exportDataDesc'), icon: IconDownload, color: 'blue' },
|
||||
{ label: t('dashboard.quickLinks.auditLog'), desc: t('dashboard.quickLinks.auditLogDesc'), icon: IconClipboardList, color: 'grape' },
|
||||
];
|
||||
}
|
||||
|
||||
type UserStatus = 'Active' | 'Pending' | 'Invited';
|
||||
const STATUS_COLOR: Record<UserStatus, string> = {
|
||||
Active: 'teal',
|
||||
Pending: 'orange',
|
||||
Invited: 'blue',
|
||||
};
|
||||
|
||||
const RECENT_USERS: {
|
||||
name: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
color: string;
|
||||
status: UserStatus;
|
||||
}[] = [
|
||||
{ name: 'Sara Tesfaye', email: 'sara.t@ema.gov.et', initials: 'ST', color: 'blue', status: 'Active' },
|
||||
{ name: 'Daniel Bekele', email: 'daniel.b@ema.gov.et', initials: 'DB', color: 'indigo', status: 'Active' },
|
||||
{ name: 'Hanna Girma', email: 'hanna.g@ema.gov.et', initials: 'HG', color: 'violet', status: 'Pending' },
|
||||
{ name: 'Yonas Alemu', email: 'yonas.a@ema.gov.et', initials: 'YA', color: 'cyan', status: 'Active' },
|
||||
{ name: 'Meron Tadesse', email: 'meron.t@ema.gov.et', initials: 'MT', color: 'grape', status: 'Invited' },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* small building blocks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function SectionCard({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} fz="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
trend,
|
||||
up,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ElementType;
|
||||
color: string;
|
||||
trend: string;
|
||||
up: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<ThemeIcon size={46} radius="md" variant="light" color={color}>
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={up ? 'teal' : 'red'}
|
||||
radius="sm"
|
||||
leftSection={up ? <IconArrowUpRight size={12} /> : <IconArrowDownRight size={12} />}
|
||||
>
|
||||
{trend}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={30} fw={800} mt="md" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{label}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinkTile({
|
||||
label,
|
||||
desc,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: ElementType;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: 'var(--mantine-spacing-md)',
|
||||
transition: 'border-color 120ms ease, box-shadow 120ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--mantine-color-blue-3)';
|
||||
e.currentTarget.style.boxShadow = 'var(--mantine-shadow-sm)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--mantine-color-gray-2)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={42} radius="md" variant="light" color={color}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm" mt="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{desc}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Backoffice home.
|
||||
*
|
||||
* Shows the licence pipeline, which is the part of the platform that has real
|
||||
* data behind it. The previous version charted invented registration volumes
|
||||
* and a fictional breakdown of staff roles.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState('week');
|
||||
const kpis = useKpis(t);
|
||||
const quickLinks = useQuickLinks(t);
|
||||
const navigate = useNavigate();
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
|
||||
const stats = [
|
||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
||||
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
|
||||
{
|
||||
label: 'Needs applicant action',
|
||||
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
label: 'Awaiting payment',
|
||||
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
|
||||
color: 'yellow',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
{/* header */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>{t('dashboard.title')}</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('dashboard.subtitle')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
radius="md"
|
||||
data={[
|
||||
{ label: t('dashboard.timeRange.today'), value: 'today' },
|
||||
{ label: t('dashboard.timeRange.thisWeek'), value: 'week' },
|
||||
{ label: t('dashboard.timeRange.thisMonth'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
<Button leftSection={<IconDownload size={16} />} radius="md">
|
||||
{t('common.export')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Dashboard
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the system.
|
||||
</Text>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
{kpis.map((k) => (
|
||||
<StatCard key={k.label} {...k} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* charts */}
|
||||
<Grid gutter="lg" align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="flex-start" mb="lg">
|
||||
<CardHeading
|
||||
title={t('dashboard.charts.userRegistrations')}
|
||||
subtitle={t('dashboard.charts.registrationsSubtitle')}
|
||||
/>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text fw={700} c="teal">
|
||||
+18.2%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.vsPreviousPeriod')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Box h={260}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={REGISTRATIONS} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="barBlue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#60a5fa" />
|
||||
<stop offset="100%" stopColor="#2563eb" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="#eef2f7" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#8d9bb3' }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: '#f1f5fb' }}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e4e9f2',
|
||||
fontSize: 12,
|
||||
boxShadow: '0 4px 20px rgba(15,23,42,0.08)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]} maxBarSize={34}>
|
||||
{REGISTRATIONS.map((entry, i) => (
|
||||
<Cell
|
||||
key={entry.month}
|
||||
fill={i === REGISTRATIONS.length - 1 ? '#1d4ed8' : 'url(#barBlue)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<SectionCard>
|
||||
<CardHeading title={t('dashboard.charts.usersByRole')} subtitle={t('dashboard.charts.roleDistribution')} />
|
||||
<Group mt="lg" justify="center" wrap="nowrap" gap="lg">
|
||||
<Box pos="relative" w={168} h={168} style={{ flexShrink: 0 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={ROLES}
|
||||
dataKey="value"
|
||||
innerRadius={56}
|
||||
outerRadius={82}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<Cell key={r.name} fill={r.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e4e9f2',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, name) => [`${value}%`, name]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<Group justify="space-between" p="md" pb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Awaiting claim
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
||||
</Text>
|
||||
</Group>
|
||||
{unclaimed.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting to be claimed.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{unclaimed.slice(0, 8).map((app) => (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
<Text fw={800} fz={22}>
|
||||
9,431
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.totalUsers')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
{ROLES.map((r) => (
|
||||
<Group key={r.name} justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box w={10} h={10} style={{ borderRadius: 999, background: r.color }} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700}>
|
||||
{r.value}%
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* quick links + recent users */}
|
||||
<Grid gutter="lg" align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<SectionCard>
|
||||
<CardHeading title={t('dashboard.quickLinks.title')} subtitle={t('dashboard.quickLinks.subtitle')} />
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md" mt="lg">
|
||||
{quickLinks.map((q) => (
|
||||
<QuickLinkTile key={q.label} {...q} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={STATUS_COLORS[app.status as LicenseStatus]}
|
||||
>
|
||||
{STATUS_LABELS[app.status as LicenseStatus]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<SectionCard>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="lg">
|
||||
{t('dashboard.recentUsers.title')}
|
||||
</Text>
|
||||
<Anchor size="sm" fw={600}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{t('common.viewAll')}
|
||||
<IconArrowRight size={14} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
{RECENT_USERS.map((u, i) => (
|
||||
<Group
|
||||
key={u.email}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom:
|
||||
i < RECENT_USERS.length - 1
|
||||
? '1px solid var(--mantine-color-gray-2)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar color={u.color} radius="xl" size={38}>
|
||||
{u.initials}
|
||||
</Avatar>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{u.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{u.email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge variant="light" color={STATUS_COLOR[u.status]} radius="sm">
|
||||
{u.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,318 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endorsement = STCW Reg I/10 — flag state endorsement of a foreign-issued CoC.
|
||||
// Workflow: Application Submitted → Document Verification → Endorsement Issued / Rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EndorsementStatus =
|
||||
| 'Submitted'
|
||||
| 'Document Verification'
|
||||
| 'Pending Approval'
|
||||
| 'Endorsed'
|
||||
| 'Rejected';
|
||||
|
||||
export interface EndorsementApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
submitted: string;
|
||||
status: EndorsementStatus;
|
||||
paymentStatus: 'Verified' | 'Pending';
|
||||
// Foreign CoC details
|
||||
foreignCocNumber: string;
|
||||
foreignCocIssuer: string;
|
||||
foreignCocCountry: string;
|
||||
foreignCocIssueDate: string;
|
||||
foreignCocExpiryDate: string;
|
||||
foreignCocType: string;
|
||||
stcwRef: string;
|
||||
// Endorsement result
|
||||
endorsementNumber: string;
|
||||
endorsementIssueDate: string;
|
||||
endorsementExpiryDate: string;
|
||||
officerRemarks: string;
|
||||
// Docs
|
||||
foreignCocFile: string;
|
||||
certifiedTranslationFile: string;
|
||||
seamanBookFile: string;
|
||||
medicalFile: string;
|
||||
photoFile: string;
|
||||
}
|
||||
|
||||
export const ENDORSEMENT_STATUS_COLOR: Record<EndorsementStatus, string> = {
|
||||
'Submitted': 'gray',
|
||||
'Document Verification': 'blue',
|
||||
'Pending Approval': 'yellow',
|
||||
'Endorsed': 'teal',
|
||||
'Rejected': 'red',
|
||||
};
|
||||
|
||||
export const MOCK_ENDORSEMENT_APPS: EndorsementApp[] = [
|
||||
{
|
||||
id: 'END-APP-2025-001',
|
||||
seafarerId: 'SF-2024-0010',
|
||||
name: 'Bereket Alemu',
|
||||
email: 'bereket.a@email.com',
|
||||
mobile: '+251 911 100 200',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-05',
|
||||
status: 'Document Verification',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'PHL-COC-2022-0045',
|
||||
foreignCocIssuer: 'Maritime Industry Authority (MARINA)',
|
||||
foreignCocCountry: 'Philippines',
|
||||
foreignCocIssueDate: '2022-06-15',
|
||||
foreignCocExpiryDate: '2027-06-15',
|
||||
foreignCocType: 'Officer in Charge of a Navigational Watch',
|
||||
stcwRef: 'STCW Reg. II/1',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: '',
|
||||
foreignCocFile: 'bereket_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: 'bereket_seaman_book.pdf',
|
||||
medicalFile: 'bereket_medical.pdf',
|
||||
photoFile: 'bereket_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-002',
|
||||
seafarerId: 'SF-2024-0011',
|
||||
name: 'Hiwot Girma',
|
||||
email: 'hiwot.g@email.com',
|
||||
mobile: '+251 922 200 300',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-10',
|
||||
status: 'Pending Approval',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'GRC-COC-2021-0088',
|
||||
foreignCocIssuer: 'Hellenic Coast Guard',
|
||||
foreignCocCountry: 'Greece',
|
||||
foreignCocIssueDate: '2021-09-20',
|
||||
foreignCocExpiryDate: '2026-09-20',
|
||||
foreignCocType: 'Officer in Charge of an Engineering Watch',
|
||||
stcwRef: 'STCW Reg. III/1',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: 'All documents verified. Awaiting senior officer approval for endorsement issuance.',
|
||||
foreignCocFile: 'hiwot_foreign_coc.pdf',
|
||||
certifiedTranslationFile: 'hiwot_translation.pdf',
|
||||
seamanBookFile: 'hiwot_seaman_book.pdf',
|
||||
medicalFile: 'hiwot_medical.pdf',
|
||||
photoFile: 'hiwot_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-003',
|
||||
seafarerId: 'SF-2023-0055',
|
||||
name: 'Mulat Bekele',
|
||||
email: 'mulat.b@email.com',
|
||||
mobile: '+251 933 300 400',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-03-20',
|
||||
status: 'Endorsed',
|
||||
paymentStatus: 'Verified',
|
||||
foreignCocNumber: 'KOR-COC-2020-0112',
|
||||
foreignCocIssuer: 'Korea Maritime & Ocean University',
|
||||
foreignCocCountry: 'South Korea',
|
||||
foreignCocIssueDate: '2020-03-10',
|
||||
foreignCocExpiryDate: '2025-03-10',
|
||||
foreignCocType: 'Chief Mate',
|
||||
stcwRef: 'STCW Reg. II/2',
|
||||
endorsementNumber: 'EMA-END-2025-003',
|
||||
endorsementIssueDate: '2025-04-01',
|
||||
endorsementExpiryDate: '2025-03-10',
|
||||
officerRemarks: 'Foreign CoC verified. Endorsement issued in accordance with STCW Reg I/10. Note: foreign CoC expires 2025-03-10; endorsement co-terminous.',
|
||||
foreignCocFile: 'mulat_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: 'mulat_seaman_book.pdf',
|
||||
medicalFile: 'mulat_medical.pdf',
|
||||
photoFile: 'mulat_photo.jpg',
|
||||
},
|
||||
{
|
||||
id: 'END-APP-2025-004',
|
||||
seafarerId: 'SF-2024-0022',
|
||||
name: 'Tigist Haile',
|
||||
email: 'tigist.h@email.com',
|
||||
mobile: '+251 944 400 500',
|
||||
nationality: 'Ethiopian',
|
||||
submitted: '2025-04-18',
|
||||
status: 'Submitted',
|
||||
paymentStatus: 'Pending',
|
||||
foreignCocNumber: 'IND-COC-2023-0445',
|
||||
foreignCocIssuer: 'Directorate General of Shipping',
|
||||
foreignCocCountry: 'India',
|
||||
foreignCocIssueDate: '2023-11-01',
|
||||
foreignCocExpiryDate: '2028-11-01',
|
||||
foreignCocType: 'Electro-Technical Officer (ETO)',
|
||||
stcwRef: 'STCW Reg. III/6',
|
||||
endorsementNumber: '',
|
||||
endorsementIssueDate: '',
|
||||
endorsementExpiryDate: '',
|
||||
officerRemarks: '',
|
||||
foreignCocFile: 'tigist_foreign_coc.pdf',
|
||||
certifiedTranslationFile: '',
|
||||
seamanBookFile: '',
|
||||
medicalFile: '',
|
||||
photoFile: '',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = ['All', 'Submitted', 'Document Verification', 'Pending Approval', 'Endorsed', 'Rejected'];
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function EndorsementQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
|
||||
const filtered = MOCK_ENDORSEMENT_APPS.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'All' || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: MOCK_ENDORSEMENT_APPS.length,
|
||||
pending: MOCK_ENDORSEMENT_APPS.filter((a) => ['Submitted', 'Document Verification', 'Pending Approval'].includes(a.status)).length,
|
||||
endorsed: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Endorsed').length,
|
||||
rejected: MOCK_ENDORSEMENT_APPS.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Endorsement Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Process flag-state endorsements of foreign-issued CoC certificates (STCW Reg I/10)</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue', icon: IconRubberStamp },
|
||||
{ label: 'Awaiting Review', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Endorsed', value: stats.endorsed, color: 'teal', icon: IconCircleCheck },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red', icon: IconShieldCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Endorsement Applications</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v ?? 'All')}
|
||||
size="sm"
|
||||
style={{ width: rem(200) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconRubberStamp size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Foreign CoC Type', 'Issued By', 'Country', 'Expiry', 'Payment', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" fw={500}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocType}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={140} lh={1.3}>{app.foreignCocIssuer}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.foreignCocCountry}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.foreignCocExpiryDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'}>
|
||||
{app.paymentStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/endorsement-queue/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {MOCK_ENDORSEMENT_APPS.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsement queue"
|
||||
description="Endorsement processing is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementQueuePage;
|
||||
|
||||
@@ -1,579 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconCreditCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
MOCK_ENDORSEMENT_APPS,
|
||||
ENDORSEMENT_STATUS_COLOR,
|
||||
} from './EndorsementQueuePage';
|
||||
import type { EndorsementApp, EndorsementStatus } from './EndorsementQueuePage';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Doc viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
const DEMO_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
function getDocUrl(fileName: string) {
|
||||
if (!fileName) return '';
|
||||
return fileName.endsWith('.pdf') ? DEMO_PDF : `https://placehold.co/600x400/e9ecef/6c757d?text=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
function DocViewer({ fileName, label }: { fileName: string; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPdf = fileName.endsWith('.pdf');
|
||||
const url = getDocUrl(fileName);
|
||||
if (!fileName) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="md" variant="light" color="red" radius="md"><IconFileDescription size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Badge size="xs" color="red" variant="light">Not uploaded</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card withBorder radius="md" p="sm">
|
||||
<Group gap="sm" wrap="nowrap" mb="sm">
|
||||
<ThemeIcon size="md" variant="light" color={isPdf ? 'red' : 'blue'} radius="md">
|
||||
<IconFileDescription size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" truncate>{fileName}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={isPdf ? 'red' : 'blue'}>{isPdf ? 'PDF' : 'IMG'}</Badge>
|
||||
</Group>
|
||||
<Box style={{ width: '100%', height: rem(180), borderRadius: rem(6), overflow: 'hidden', border: '1px solid var(--mantine-color-default-border)', background: 'var(--mantine-color-gray-0)' }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Box>
|
||||
<Button size="xs" variant="subtle" fullWidth mt="xs" leftSection={<IconEye size={13} />} rightSection={<IconExternalLink size={13} />} onClick={() => setOpen(true)}>
|
||||
View Full Document
|
||||
</Button>
|
||||
</Card>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Text fw={700}>{label} — {fileName}</Text>} size="90vw" styles={{ body: { padding: 0, height: '80vh' } }}>
|
||||
{isPdf
|
||||
? <iframe src={url} style={{ width: '100%', height: '100%', border: 'none' }} title={fileName} />
|
||||
: <img src={url} alt={fileName} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 SectionLabel({ children }: { children: string }) {
|
||||
return <Text fw={600} fz="xs" tt="uppercase" c="gray.6" mb="sm">{children}</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow bar
|
||||
// ---------------------------------------------------------------------------
|
||||
const STEP_ORDER: EndorsementStatus[] = ['Submitted', 'Document Verification', 'Pending Approval', 'Endorsed'];
|
||||
const STEP_LABELS = ['Submitted', 'Doc Verification', 'Pending Approval', 'Endorsed'];
|
||||
|
||||
function WorkflowBar({ status }: { status: EndorsementStatus }) {
|
||||
const activeIdx = STEP_ORDER.indexOf(status);
|
||||
const isRejected = status === 'Rejected';
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" mb="md">
|
||||
<Text fz="xs" c="dimmed" fw={700} tt="uppercase" mb="xs">Endorsement Progress</Text>
|
||||
<Group gap={0} align="flex-start" wrap="nowrap">
|
||||
{STEP_ORDER.map((s, i) => {
|
||||
const done = !isRejected && activeIdx > i;
|
||||
const current = !isRejected && activeIdx === i;
|
||||
return (
|
||||
<Group key={s} gap={0} align="center" style={{ flex: i < STEP_ORDER.length - 1 ? 1 : 'none' }}>
|
||||
<Stack gap={2} align="center" style={{ minWidth: rem(60) }}>
|
||||
<Box style={{
|
||||
width: rem(30), height: rem(30), borderRadius: '50%',
|
||||
background: done ? 'var(--mantine-color-teal-6)' : current ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{done
|
||||
? <IconCheck size={14} color="white" stroke={2.5} />
|
||||
: <Text fz="xs" fw={700} c={current ? 'white' : 'gray.5'}>{i + 1}</Text>}
|
||||
</Box>
|
||||
<Text fz={9} fw={current || done ? 700 : 400} c={done ? 'teal.7' : current ? 'blue.7' : 'dimmed'} ta="center" lh={1.2}>{STEP_LABELS[i]}</Text>
|
||||
</Stack>
|
||||
{i < STEP_ORDER.length - 1 && (
|
||||
<Box style={{ flex: 1, height: rem(2), background: done ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-gray-2)', marginBottom: rem(20) }} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
{isRejected && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={13} />} p="xs" mt="xs">
|
||||
<Text fz="xs" fw={600}>Application rejected. Applicant must submit a new application.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action modal
|
||||
// ---------------------------------------------------------------------------
|
||||
type ActionKey = 'verify_docs' | 'request_docs' | 'pending_approval' | 'endorse' | 'reject';
|
||||
|
||||
function getActions(status: EndorsementStatus): { value: ActionKey; label: string }[] {
|
||||
switch (status) {
|
||||
case 'Submitted':
|
||||
return [
|
||||
{ value: 'verify_docs', label: 'Start Document Verification' },
|
||||
{ value: 'request_docs', label: 'Request Missing Documents' },
|
||||
];
|
||||
case 'Document Verification':
|
||||
return [
|
||||
{ value: 'pending_approval', label: 'Documents Verified — Submit for Approval' },
|
||||
{ value: 'request_docs', label: 'Request Additional Documents' },
|
||||
{ value: 'reject', label: 'Reject Application' },
|
||||
];
|
||||
case 'Pending Approval':
|
||||
return [
|
||||
{ value: 'endorse', label: 'Issue Endorsement' },
|
||||
{ value: 'reject', label: 'Reject Application' },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_TO_STATUS: Record<ActionKey, EndorsementStatus> = {
|
||||
verify_docs: 'Document Verification',
|
||||
request_docs: 'Document Verification',
|
||||
pending_approval: 'Pending Approval',
|
||||
endorse: 'Endorsed',
|
||||
reject: 'Rejected',
|
||||
};
|
||||
|
||||
function ActionModal({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
app: EndorsementApp;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [endNo, setEndNo] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
|
||||
const actionOptions = getActions(app.status);
|
||||
const isEndorse = action === 'endorse';
|
||||
const isDestructive = action === 'reject';
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!action) return;
|
||||
if (isEndorse && (!endNo || !issueDate || !expiryDate)) {
|
||||
notify.error('Please fill in endorsement number, issue date and expiry date.'); return;
|
||||
}
|
||||
const newStatus = ACTION_TO_STATUS[action as ActionKey];
|
||||
onAction(newStatus, remarks, endNo || undefined, issueDate || undefined, expiryDate || undefined);
|
||||
setAction(null); setRemarks(''); setEndNo(''); setIssueDate(''); setExpiryDate('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconRubberStamp size={17} /><Text fw={700}>Officer Action</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{actionOptions.length === 0 ? (
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={15} />}>
|
||||
<Text fz="sm" fw={600}>No further actions available.</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Action"
|
||||
placeholder="Select an action…"
|
||||
data={actionOptions}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isEndorse && (
|
||||
<>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} p="xs">
|
||||
<Text fz="xs">The endorsement is co-terminous with the foreign CoC. Expiry date must not exceed the foreign CoC expiry ({app.foreignCocExpiryDate}).</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Endorsement Number"
|
||||
placeholder="EMA-END-2025-XXX"
|
||||
value={endNo}
|
||||
onChange={(e) => setEndNo(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} size="sm" required />
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Remarks / Notes (sent to applicant)"
|
||||
placeholder="Verification findings, reason for rejection, or additional instructions…"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{isDestructive && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="xs" fw={600}>This will permanently reject the application. The applicant must submit a new application.</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
leftSection={<IconCheck size={14} />}
|
||||
disabled={!action}
|
||||
color={isDestructive ? 'red' : isEndorse ? 'teal' : 'blue'}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main review page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function EndorsementReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const original = MOCK_ENDORSEMENT_APPS.find((a) => a.id === id);
|
||||
const [app, setApp] = useState<EndorsementApp | null>(original ?? null);
|
||||
const [actionModalOpen, setActionModalOpen] = useState(false);
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/endorsement-queue')}>Back to Queue</Button>
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />}>Application not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAction = (newStatus: EndorsementStatus, remarks: string, endNo?: string, issueDate?: string, expiryDate?: string) => {
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
officerRemarks: remarks || prev.officerRemarks,
|
||||
endorsementNumber: endNo ?? prev.endorsementNumber,
|
||||
endorsementIssueDate: issueDate ?? prev.endorsementIssueDate,
|
||||
endorsementExpiryDate: expiryDate ?? prev.endorsementExpiryDate,
|
||||
} : prev);
|
||||
|
||||
const msgs: Record<EndorsementStatus, string> = {
|
||||
'Submitted': 'moved to Submitted',
|
||||
'Document Verification': 'moved to Document Verification',
|
||||
'Pending Approval': 'documents verified — submitted for approval',
|
||||
'Endorsed': `endorsement ${endNo} issued`,
|
||||
'Rejected': 'application rejected',
|
||||
};
|
||||
notify.success(`Application ${msgs[newStatus]}.`);
|
||||
};
|
||||
|
||||
const isTerminal = app.status === 'Endorsed' || app.status === 'Rejected';
|
||||
const actionOptions = getActions(app.status);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/endorsement-queue')}>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Title order={3}>Endorsement Application Review</Title>
|
||||
<Group gap={6} mt={2}>
|
||||
<Text fz="sm" c="dimmed">{app.id}</Text>
|
||||
<Text fz="sm" c="dimmed">·</Text>
|
||||
<Text fz="sm" c="dimmed">Submitted {app.submitted}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light" color={ENDORSEMENT_STATUS_COLOR[app.status]}>{app.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Action bar */}
|
||||
{!isTerminal && actionOptions.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="md" bg="gray.0">
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Text fz="sm" c="dimmed" style={{ flex: 1 }}>Review all tabs, then take an action:</Text>
|
||||
<Button size="sm" leftSection={<IconRubberStamp size={15} />} onClick={() => setActionModalOpen(true)}>
|
||||
Take Action
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{isTerminal && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={app.status === 'Endorsed' ? 'teal' : 'red'}
|
||||
icon={app.status === 'Endorsed' ? <IconCircleCheck size={17} /> : <IconAlertTriangle size={17} />}
|
||||
>
|
||||
<Text fz="sm" fw={600}>
|
||||
{app.status === 'Endorsed' && `Endorsement ${app.endorsementNumber} issued on ${app.endorsementIssueDate}.`}
|
||||
{app.status === 'Rejected' && 'Application rejected. Applicant must submit a new application.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<WorkflowBar status={app.status} />
|
||||
|
||||
<Tabs defaultValue="applicant" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="applicant" leftSection={<IconUser size={16} />}>Applicant</Tabs.Tab>
|
||||
<Tabs.Tab value="foreign-coc" leftSection={<IconShieldCheck size={16} />}>Foreign CoC</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<IconFileDescription size={16} />}>Documents</Tabs.Tab>
|
||||
<Tabs.Tab value="payment" leftSection={<IconCreditCard size={16} />}>Payment</Tabs.Tab>
|
||||
<Tabs.Tab value="endorsement" leftSection={<IconRubberStamp size={16} />}>Endorsement</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Applicant ─────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="applicant">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" radius="lg"><IconUser size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">{app.name}</Text>
|
||||
<Text fz="sm" c="dimmed">{app.seafarerId}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SectionLabel>Personal Information</SectionLabel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Email" value={app.email} />
|
||||
<InfoRow label="Mobile" value={app.mobile} />
|
||||
<InfoRow label="Nationality" value={app.nationality} />
|
||||
<InfoRow label="Submitted" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Foreign CoC ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="foreign-coc">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="blue" radius="lg"><IconShieldCheck size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Foreign Certificate of Competency</Text>
|
||||
<Text fz="xs" c="dimmed">{app.stcwRef}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="lg">
|
||||
<InfoRow label="CoC Number" value={app.foreignCocNumber} />
|
||||
<InfoRow label="Certificate Type" value={app.foreignCocType} />
|
||||
<InfoRow label="Issuing Authority" value={app.foreignCocIssuer} />
|
||||
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
|
||||
<InfoRow label="Issue Date" value={app.foreignCocIssueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.foreignCocExpiryDate} />
|
||||
<InfoRow label="STCW Reference" value={app.stcwRef} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="sm">
|
||||
<Text fz="xs">
|
||||
<strong>Verification checklist (STCW Reg I/10):</strong> Confirm the issuing state is an STCW party,
|
||||
the certificate is authentic, valid, and not suspended or cancelled, and the seafarer holds the
|
||||
required sea service and medical fitness.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Box maw={360} mt="md">
|
||||
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC" />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Supporting Documents ──────────────────────────────── */}
|
||||
<Tabs.Panel value="documents">
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
Verify all supporting documents. A certified translation is required if the foreign CoC is not in English.
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<DocViewer fileName={app.foreignCocFile} label="Foreign CoC (Original or Certified Copy)" />
|
||||
<DocViewer fileName={app.certifiedTranslationFile} label="Certified Translation (if not English)" />
|
||||
<DocViewer fileName={app.seamanBookFile} label="Ethiopian Seaman Book" />
|
||||
<DocViewer fileName={app.medicalFile} label="Valid Medical Fitness Certificate (ENG I/2)" />
|
||||
<DocViewer fileName={app.photoFile} label="Passport-Size Photo" />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Payment ───────────────────────────────────────────── */}
|
||||
<Tabs.Panel value="payment">
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="green" radius="lg"><IconCreditCard size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Payment</Text>
|
||||
<Badge size="sm" variant="light" color={app.paymentStatus === 'Verified' ? 'teal' : 'orange'} mt={2}>{app.paymentStatus}</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
<Text fw={700} fz="sm" mb="md">Endorsement Fee Breakdown</Text>
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 300 },
|
||||
{ label: 'Document Verification Fee', amount: 200 },
|
||||
{ label: 'Endorsement Issuance Fee', amount: 500 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb={4}>
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={500}>ETB {amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider mt="sm" mb="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={800}>Total</Text>
|
||||
<Text fz="md" fw={800} c="blue">ETB 1,000.00</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoRow label="Payment Method" value="CBE Bank Transfer" />
|
||||
<InfoRow label="Transaction Reference" value="CBE-TXN-20250405-088" />
|
||||
<InfoRow label="Payment Date" value={app.submitted} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Endorsement ───────────────────────────────────────── */}
|
||||
<Tabs.Panel value="endorsement">
|
||||
<Stack gap="md">
|
||||
{app.status === 'Endorsed' ? (
|
||||
<>
|
||||
<Alert variant="light" color="teal" icon={<IconCircleCheck size={17} />}>
|
||||
Endorsement has been issued. The seafarer has been notified.
|
||||
</Alert>
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group gap="sm" mb="lg">
|
||||
<ThemeIcon size="xl" variant="light" color="teal" radius="lg"><IconRubberStamp size={22} stroke={1.5} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="lg">Issued Endorsement</Text>
|
||||
<Text fz="xs" c="dimmed">STCW Reg I/10 — Flag State Endorsement</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Divider mb="md" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<InfoRow label="Endorsement Number" value={app.endorsementNumber} />
|
||||
<InfoRow label="Issue Date" value={app.endorsementIssueDate} />
|
||||
<InfoRow label="Expiry Date" value={app.endorsementExpiryDate} />
|
||||
<InfoRow label="Certificate Type" value={app.foreignCocType} />
|
||||
<InfoRow label="Original CoC No." value={app.foreignCocNumber} />
|
||||
<InfoRow label="Issuing Country" value={app.foreignCocCountry} />
|
||||
</SimpleGrid>
|
||||
{app.officerRemarks && (
|
||||
<>
|
||||
<Divider mt="md" mb="md" />
|
||||
<SectionLabel>Officer Remarks</SectionLabel>
|
||||
<Text fz="sm">{app.officerRemarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : app.status === 'Rejected' ? (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Alert variant="light" color="red" icon={<IconAlertTriangle size={17} />} mb="md">
|
||||
Application rejected. No endorsement was issued.
|
||||
</Alert>
|
||||
{app.officerRemarks && (
|
||||
<>
|
||||
<SectionLabel>Rejection Reason</SectionLabel>
|
||||
<Text fz="sm">{app.officerRemarks}</Text>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
) : (
|
||||
<Alert variant="light" color="yellow" icon={<IconInfoCircle size={17} />}>
|
||||
Endorsement will be issued here once all documents are verified and the application is approved.
|
||||
The endorsement is co-terminous with the foreign CoC (expires {app.foreignCocExpiryDate}).
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ActionModal
|
||||
app={app}
|
||||
opened={actionModalOpen}
|
||||
onClose={() => setActionModalOpen(false)}
|
||||
onAction={handleAction}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsement review"
|
||||
description="Endorsement processing is not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementReviewPage;
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconTruck,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type LicenseStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Payment Pending'
|
||||
| 'Payment Confirmed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface FreightForwarderApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
commercialRegNumber: string;
|
||||
businessAddress: string;
|
||||
bankName: string;
|
||||
capitalAmount: number;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_FF_APPLICATIONS: FreightForwarderApplication[] = [
|
||||
{
|
||||
id: 'FF-2024-001',
|
||||
companyName: 'Horizon Freight Solutions PLC',
|
||||
tinNumber: 'TIN-0012345',
|
||||
commercialRegNumber: 'CR-889001',
|
||||
businessAddress: 'Addis Ababa, Bole Sub-city',
|
||||
bankName: 'Commercial Bank of Ethiopia',
|
||||
capitalAmount: 1800000,
|
||||
status: 'Under Evaluation',
|
||||
submittedDate: '2024-03-10',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: 'Bank letter and employee documents under review.',
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'FF-2024-002',
|
||||
companyName: 'Nile Cargo Movers Ltd',
|
||||
tinNumber: 'TIN-0056789',
|
||||
commercialRegNumber: 'CR-771002',
|
||||
businessAddress: 'Dire Dawa',
|
||||
bankName: 'Awash Bank',
|
||||
capitalAmount: 1450000,
|
||||
status: 'Submitted',
|
||||
submittedDate: '2024-04-01',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: '',
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'FF-2023-014',
|
||||
companyName: 'Abyssinia Logistics PLC',
|
||||
tinNumber: 'TIN-0034521',
|
||||
commercialRegNumber: 'CR-660214',
|
||||
businessAddress: 'Addis Ababa, Kirkos Sub-city',
|
||||
bankName: 'Zemen Bank',
|
||||
capitalAmount: 2100000,
|
||||
status: 'Certificate Issued',
|
||||
submittedDate: '2023-10-05',
|
||||
approvalDate: '2023-11-02',
|
||||
expiryDate: '2024-11-02',
|
||||
remarks: 'All requirements verified. Certificate issued.',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
'Payment Pending': 'grape',
|
||||
'Payment Confirmed': 'indigo',
|
||||
'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: FreightForwarderApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
|
||||
{ label: 'Business Address', value: app.businessAddress },
|
||||
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Bank Letter (≥ 1.5M ETB)', ok: app.docsComplete },
|
||||
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'CEO CV & Work Agreement', ok: app.docsComplete },
|
||||
{ label: '2 Qualified Transit Employees (ERB Certificates)', ok: app.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function FreightForwarderLicenseQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<FreightForwarderApplication[]>(MOCK_FF_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<FreightForwarderApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<FreightForwarderApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/freight-forwarder?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: LicenseStatus =
|
||||
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved — pending payment.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
|
||||
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/freight-forwarder-license/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Freight Forwarder License Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Freight Forwarder License applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Bank Letter Amount</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/freight-forwarder-license/${id}`)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const FF_ICON = IconTruck;
|
||||
@@ -1,297 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconTruck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_FF_APPLICATIONS, STATUS_COLOR } from './FreightForwarderLicenseQueuePage';
|
||||
import type { FreightForwarderApplication, LicenseStatus } from './FreightForwarderLicenseQueuePage';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeExpiryDate(approvalDate: string): string {
|
||||
const d = new Date(approvalDate);
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const DOCS = [
|
||||
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.5M ETB)', fileName: 'bank_letter.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', fileName: 'office_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'ceoDocs', label: 'CEO CV & Work Agreement', fileName: 'ceo_docs.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'adminDocs', label: 'Administrative Staff CV & Work Agreement', fileName: 'admin_docs.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'transitErb', label: '2 Transit/Customs Employees — ERB Certificates', fileName: 'erb_certificates.pdf', icon: IconShieldCheck, required: true },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
|
||||
];
|
||||
|
||||
export function FreightForwarderLicenseReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<FreightForwarderApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [app, setApp] = useState<FreightForwarderApplication | null>(null);
|
||||
const [status, setStatus] = useState<LicenseStatus>('Submitted');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/logistics-licenses/freight-forwarder/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_FF_APPLICATIONS.find((a) => a.id === id) ?? null;
|
||||
setApp(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !app) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as LicenseStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks,
|
||||
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
|
||||
} : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Application status updated to ${newStatus}.`);
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Application not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/freight-forwarder-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/freight-forwarder-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconTruck size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{app.companyName}</Title>
|
||||
<Text fz="sm" c="dimmed">{app.id} · Freight Forwarder License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
|
||||
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
|
||||
This application has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">Take Action</Text>
|
||||
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
|
||||
Update Status
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Company Name" value={app.companyName} />
|
||||
<InfoRow label="TIN Number" value={app.tinNumber} />
|
||||
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
|
||||
<InfoRow label="Business Address" value={app.businessAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Bank Letter / Capital Evidence</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Bank Name" value={app.bankName} />
|
||||
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
|
||||
<InfoRow label="Minimum Required" value="1,500,000 ETB" />
|
||||
<InfoRow label="Meets Threshold" value={app.capitalAmount >= 1500000 ? 'Yes' : 'No'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
const ok = app.docsComplete;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
|
||||
<DocIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="xs">
|
||||
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
{ok ? (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={app.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
|
||||
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
|
||||
<IconCertificate size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="md" c="teal.7">Issued Certificate — Freight Forwarder License</Text>
|
||||
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Freight Forwarder License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
|
||||
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the applicant..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconBuildingBank,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type LicenseStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Completed';
|
||||
|
||||
export interface JointInvestmentApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
commercialRegNumber: string;
|
||||
businessAddress: string;
|
||||
bankName: string;
|
||||
capitalAmount: number;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_JV_APPLICATIONS: JointInvestmentApplication[] = [
|
||||
{
|
||||
id: 'JV-2024-001',
|
||||
companyName: 'Abyssinia-Sino Joint Venture PLC',
|
||||
tinNumber: 'TIN-0098231',
|
||||
commercialRegNumber: 'CR-990112',
|
||||
businessAddress: 'Addis Ababa, Bole Sub-city',
|
||||
bankName: 'Commercial Bank of Ethiopia',
|
||||
capitalAmount: 1600000,
|
||||
status: 'Under Evaluation',
|
||||
submittedDate: '2024-03-12',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: 'Shareholder documentation and capital contribution under review.',
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'JV-2024-002',
|
||||
companyName: 'Nile-Gulf Logistics Partners Ltd',
|
||||
tinNumber: 'TIN-0071122',
|
||||
commercialRegNumber: 'CR-881203',
|
||||
businessAddress: 'Dire Dawa',
|
||||
bankName: 'Awash Bank',
|
||||
capitalAmount: 1200000,
|
||||
status: 'Submitted',
|
||||
submittedDate: '2024-04-05',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: '',
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'JV-2023-014',
|
||||
companyName: 'Horn of Africa Investment Group PLC',
|
||||
tinNumber: 'TIN-0045678',
|
||||
commercialRegNumber: 'CR-661215',
|
||||
businessAddress: 'Addis Ababa, Kirkos Sub-city',
|
||||
bankName: 'Zemen Bank',
|
||||
capitalAmount: 1900000,
|
||||
status: 'Completed',
|
||||
submittedDate: '2023-10-08',
|
||||
approvalDate: '2023-11-10',
|
||||
expiryDate: null,
|
||||
remarks: 'All requirements verified. Decision and audit history recorded.',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
Completed: 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: JointInvestmentApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Completed';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
|
||||
{ label: 'Business Address', value: app.businessAddress },
|
||||
{ label: 'Capital Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Bank Confirmation Letter / Capital Evidence', ok: app.docsComplete },
|
||||
{ label: 'Company Establishment & JV/Ownership Documents', ok: app.docsComplete },
|
||||
{ label: 'Vehicle Libre / Registration Copy', ok: app.docsComplete },
|
||||
{ label: 'Renewed Business License & Commercial Registration', ok: app.docsComplete },
|
||||
{ label: '3 Transit Professionals (Training Certificates)', ok: app.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function JointInvestmentLicenseQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<JointInvestmentApplication[]>(MOCK_JV_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<JointInvestmentApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<JointInvestmentApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/joint-investment?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: LicenseStatus =
|
||||
action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
|
||||
issued: apps.filter((a) => a.status === 'Completed').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/joint-investment-license/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Joint Investment / JV Business License Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Joint Investment / JV Business License applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Completed', value: stats.issued, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Completed']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Capital Amount</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/joint-investment-license/${id}`)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const JV_ICON = IconBuildingBank;
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconBuildingBank,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_JV_APPLICATIONS, STATUS_COLOR } from './JointInvestmentLicenseQueuePage';
|
||||
import type { JointInvestmentApplication, LicenseStatus } from './JointInvestmentLicenseQueuePage';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DOCS = [
|
||||
{ key: 'applicationLetter', label: 'Application Letter / Online Form', fileName: 'application_letter.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'establishmentDoc', label: 'Company Establishment Document', fileName: 'establishment_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'memorandum', label: 'Memorandum / Articles of Association / Bylaw', fileName: 'memorandum.pdf', icon: IconId, required: true },
|
||||
{ key: 'orgProfile', label: 'Organizational Profile', fileName: 'org_profile.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'renewedBusinessLicense', label: 'Renewed Business License', fileName: 'renewed_business_license.pdf', icon: IconId, required: true },
|
||||
{ key: 'commercialRegCert', label: 'Commercial Registration Certificate', fileName: 'commercial_reg_certificate.pdf', icon: IconId, required: true },
|
||||
{ key: 'sectorEvidence', label: 'Evidence Company Is Active in Sector', fileName: 'sector_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'transitTrainingCert', label: 'Customs Transit Training Certificate', fileName: 'transit_training_certificate.pdf', icon: IconShieldCheck, required: true },
|
||||
{ key: 'employmentContracts', label: 'Employment Contracts — 3 Transit Professionals', fileName: 'employment_contracts.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'educationEvidence', label: 'Education Evidence', fileName: 'education_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'workExperienceEvidence', label: 'Work Experience Evidence', fileName: 'work_experience_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'payrollEvidence', label: 'Three-Month Payroll Evidence', fileName: 'payroll_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'taxPaymentEvidence', label: 'Monthly Tax Payment Evidence', fileName: 'tax_payment_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'facilityEvidence', label: 'Vehicle / Machinery / Warehouse Evidence', fileName: 'facility_evidence.pdf', icon: IconId, required: true },
|
||||
{ key: 'vehicleLibre', label: 'Vehicle Libre / Registration Copy', fileName: 'vehicle_libre.pdf', icon: IconId, required: true },
|
||||
{ key: 'rentAgreement', label: 'Legal House / Office Rent Agreement', fileName: 'rent_agreement.pdf', icon: IconId, required: true },
|
||||
{ key: 'bankConfirmationLetter', label: 'Bank Confirmation Letter', fileName: 'bank_confirmation_letter.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'capitalBalanceEvidence', label: 'Capital Balance Evidence', fileName: 'capital_balance_evidence.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
|
||||
];
|
||||
|
||||
export function JointInvestmentLicenseReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<JointInvestmentApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [app, setApp] = useState<JointInvestmentApplication | null>(null);
|
||||
const [status, setStatus] = useState<LicenseStatus>('Submitted');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/logistics-licenses/joint-investment/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_JV_APPLICATIONS.find((a) => a.id === id) ?? null;
|
||||
setApp(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Rejected' || status === 'Completed';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !app) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as LicenseStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks,
|
||||
} : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Application status updated to ${newStatus}.`);
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Application not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/joint-investment-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/joint-investment-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconBuildingBank size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{app.companyName}</Title>
|
||||
<Text fz="sm" c="dimmed">{app.id} · Joint Investment / JV Business License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{status === 'Completed' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Application Completed">
|
||||
Decision and audit history recorded on {app.approvalDate}.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
|
||||
This application has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">Take Action</Text>
|
||||
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
|
||||
Update Status
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Company Name" value={app.companyName} />
|
||||
<InfoRow label="TIN Number" value={app.tinNumber} />
|
||||
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
|
||||
<InfoRow label="Business Address" value={app.businessAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Capital Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Bank Name" value={app.bankName} />
|
||||
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
const ok = app.docsComplete;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
|
||||
<DocIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="xs">
|
||||
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
{ok ? (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={app.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Completed']}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the applicant..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={selectedStatus === 'Approved' || selectedStatus === 'Completed' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Timeline,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconFileUpload,
|
||||
IconMessage,
|
||||
IconArrowRight,
|
||||
IconUserCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
type ApplicationDetail,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
kind: EntryKind;
|
||||
at: string;
|
||||
actor: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
status: IconArrowRight,
|
||||
remark: IconMessage,
|
||||
upload: IconFileUpload,
|
||||
assignment: IconUserCheck,
|
||||
};
|
||||
|
||||
/**
|
||||
* Chronological record of everything that has happened to an application.
|
||||
*
|
||||
* Merged client-side from the three collections the detail endpoint already
|
||||
* returns — status transitions, officer remarks and document uploads. There is
|
||||
* no single activity-feed endpoint, so this is assembled rather than fetched;
|
||||
* the trade-off is that it can only show what the detail payload carries, and
|
||||
* notifications sent to the applicant are not among them.
|
||||
*/
|
||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const entries = useMemo<ActivityEntry[]>(() => {
|
||||
const merged: ActivityEntry[] = [];
|
||||
|
||||
for (const history of detail.history ?? []) {
|
||||
// A transition that does not move the status is a workflow control
|
||||
// (assignment, escalation), not a decision — labelled as such so the
|
||||
// trail does not read as "Under Review → Under Review".
|
||||
const isAssignment = history.fromStatus === history.toStatus;
|
||||
merged.push({
|
||||
id: `status-${history.id}`,
|
||||
kind: isAssignment ? 'assignment' : 'status',
|
||||
at: history.createdAt,
|
||||
actor: history.actorName ?? t('review.activity.system', 'System'),
|
||||
title: isAssignment
|
||||
? t(`review.events.${history.event}`, {
|
||||
defaultValue: history.event,
|
||||
})
|
||||
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'} → ${
|
||||
STATUS_LABELS[history.toStatus]
|
||||
}`,
|
||||
detail: history.remark ?? undefined,
|
||||
color: STATUS_COLORS[history.toStatus],
|
||||
});
|
||||
}
|
||||
|
||||
for (const remark of detail.remarks ?? []) {
|
||||
merged.push({
|
||||
id: `remark-${remark.id}`,
|
||||
kind: 'remark',
|
||||
at: remark.createdAt,
|
||||
actor: t('review.activity.officer', 'Officer'),
|
||||
title: t('review.activity.remarkOn', {
|
||||
target: remark.targetKey,
|
||||
defaultValue: 'Correction requested on {{target}}',
|
||||
}),
|
||||
detail: remark.remark,
|
||||
color: remark.resolvedAt ? 'teal' : 'orange',
|
||||
});
|
||||
}
|
||||
|
||||
for (const attachment of detail.attachments ?? []) {
|
||||
const file = attachment.files?.[0];
|
||||
if (!file) continue;
|
||||
merged.push({
|
||||
id: `upload-${attachment.id}`,
|
||||
kind: 'upload',
|
||||
at: attachment.createdAt ?? detail.application.createdAt,
|
||||
actor: t('review.activity.applicant', 'Applicant'),
|
||||
title: t('review.activity.uploaded', {
|
||||
document: attachment.documentKey,
|
||||
defaultValue: 'Uploaded {{document}}',
|
||||
}),
|
||||
detail: file.originalName,
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
|
||||
// Newest first: an officer opening a review wants the latest state, not
|
||||
// the application's origin story.
|
||||
return merged.sort(
|
||||
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
|
||||
);
|
||||
}, [detail, t]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Paper withBorder p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.activity.empty', 'No activity recorded yet.')}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" h="100%">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{t('review.activity.title', 'Activity & audit trail')}
|
||||
</Text>
|
||||
<Badge variant="light" size="sm">
|
||||
{entries.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize mah={520} type="hover" offsetScrollbars>
|
||||
<Timeline bulletSize={20} lineWidth={2}>
|
||||
{entries.map((entry) => {
|
||||
const EntryIcon = ICONS[entry.kind];
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={entry.id}
|
||||
bullet={<EntryIcon size={12} />}
|
||||
color={entry.color}
|
||||
title={
|
||||
<Text size="xs" fw={600}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.actor}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={new Date(entry.at).toLocaleString(i18n.language)}
|
||||
withArrow
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.at).toLocaleDateString(i18n.language)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{entry.detail && (
|
||||
<Text size="xs" mt={2}>
|
||||
{entry.detail}
|
||||
</Text>
|
||||
)}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Text,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { IconDots, IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { STATUS_COLORS, STATUS_LABELS, type LicenseStatus } from '@ema-platform/api';
|
||||
import type { ActionId, ResolvedAction } from '../config/actions';
|
||||
import type { SlaState } from '../sla';
|
||||
|
||||
interface DecisionBarProps {
|
||||
status: LicenseStatus;
|
||||
assigneeName?: string | null;
|
||||
sla?: SlaState;
|
||||
actions: ResolvedAction[];
|
||||
busyAction?: ActionId | null;
|
||||
onAction: (action: ResolvedAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place decisions are taken.
|
||||
*
|
||||
* Sticky to the bottom of the viewport and full workspace width, so it is
|
||||
* reachable at any scroll depth — the actions used to sit in a right-hand
|
||||
* column that scrolled away, meaning an officer reading the last document had
|
||||
* to scroll back up to act on it.
|
||||
*
|
||||
* Layout follows the action tiers: workflow controls left, the decision right,
|
||||
* everything else in the overflow menu. At most one filled button, so where to
|
||||
* look is never ambiguous.
|
||||
*/
|
||||
export function DecisionBar({
|
||||
status,
|
||||
assigneeName,
|
||||
sla,
|
||||
actions,
|
||||
busyAction,
|
||||
onAction,
|
||||
}: DecisionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const workflow = actions.filter((a) => a.tier === 'workflow');
|
||||
// Three is the cap: past that the bar stops reading as a decision and starts
|
||||
// reading as a toolbar. The rest stay reachable in the overflow menu.
|
||||
const primary = actions.filter((a) => a.tier === 'primary').slice(0, 3);
|
||||
const overflowPrimary = actions.filter((a) => a.tier === 'primary').slice(3);
|
||||
const secondary = [
|
||||
...overflowPrimary,
|
||||
...actions.filter((a) => a.tier === 'secondary'),
|
||||
];
|
||||
const destructive = actions.filter((a) => a.tier === 'destructive');
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
px="lg"
|
||||
py="sm"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 60,
|
||||
borderRadius: 0,
|
||||
marginInline: `calc(-1 * var(--mantine-spacing-lg))`,
|
||||
background: 'var(--mantine-color-body)',
|
||||
}}
|
||||
role="region"
|
||||
aria-label={t('review.decisionBar', 'Decision bar')}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
{/* Left: where the application stands, and who has it. */}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||
{STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
|
||||
{assigneeName && (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Avatar size={24} radius="xl" color="blue">
|
||||
{assigneeName.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{assigneeName}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{sla && sla.state !== 'untracked' && (
|
||||
<Tooltip label={sla.tooltip} withArrow>
|
||||
<Badge
|
||||
color={sla.color}
|
||||
variant="light"
|
||||
// Never colour alone: the label carries the same meaning for
|
||||
// anyone who cannot distinguish the hues.
|
||||
leftSection={
|
||||
sla.state === 'breached' ? <IconAlertTriangle size={12} /> : undefined
|
||||
}
|
||||
>
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{workflow.length > 0 && <Divider orientation="vertical" />}
|
||||
|
||||
{workflow.map((action) => (
|
||||
<ActionButton
|
||||
key={action.id}
|
||||
action={action}
|
||||
busy={busyAction === action.id}
|
||||
onAction={onAction}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{/* Right: the decision. */}
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{primary.map((action) => (
|
||||
<ActionButton
|
||||
key={action.id}
|
||||
action={action}
|
||||
busy={busyAction === action.id}
|
||||
onAction={onAction}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
|
||||
{(secondary.length > 0 || destructive.length > 0) && (
|
||||
<Menu position="top-end" withinPortal shadow="md" width={240}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t('review.moreActions', 'More actions')}
|
||||
>
|
||||
<IconDots size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{secondary.map((action) => (
|
||||
<MenuAction key={action.id} action={action} onAction={onAction} />
|
||||
))}
|
||||
{destructive.length > 0 && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t('review.irreversible', 'Cannot be undone')}
|
||||
</Menu.Label>
|
||||
{destructive.map((action) => (
|
||||
<MenuAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
onAction={onAction}
|
||||
color="red"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: ResolvedAction;
|
||||
busy: boolean;
|
||||
size: 'xs' | 'sm';
|
||||
onAction: (action: ResolvedAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A disabled button always says why.
|
||||
*
|
||||
* Mantine strips pointer events from a disabled button, so the Tooltip has to
|
||||
* wrap a span — otherwise the one case where the explanation matters is the
|
||||
* one case it never appears.
|
||||
*/
|
||||
function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const button = (
|
||||
<Button
|
||||
size={size}
|
||||
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
|
||||
color={action.color}
|
||||
loading={busy}
|
||||
disabled={!action.enabled}
|
||||
onClick={() => onAction(action)}
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (action.enabled) return button;
|
||||
|
||||
return (
|
||||
<Tooltip label={action.disabledReason} withArrow position="top">
|
||||
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuAction({
|
||||
action,
|
||||
onAction,
|
||||
color,
|
||||
}: {
|
||||
action: ResolvedAction;
|
||||
onAction: (action: ResolvedAction) => void;
|
||||
color?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const item = (
|
||||
<Menu.Item
|
||||
color={color}
|
||||
disabled={!action.enabled}
|
||||
onClick={() => onAction(action)}
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</Menu.Item>
|
||||
);
|
||||
if (action.enabled) return item;
|
||||
return (
|
||||
<Tooltip label={action.disabledReason} withArrow position="left">
|
||||
<div>{item}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const DECISION_BAR_HEIGHT = rem(60);
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ResolvedAction } from '../config/actions';
|
||||
|
||||
/** Reason codes offered per action. Free text is always available too. */
|
||||
const REASON_CODES: Record<string, string[]> = {
|
||||
reject: [
|
||||
'review.reasons.incompleteDocuments',
|
||||
'review.reasons.belowCapital',
|
||||
'review.reasons.failedInspection',
|
||||
'review.reasons.ineligibleApplicant',
|
||||
'review.reasons.duplicateApplication',
|
||||
],
|
||||
'request-adjustment': [
|
||||
'review.reasons.illegibleDocument',
|
||||
'review.reasons.expiredDocument',
|
||||
'review.reasons.missingDocument',
|
||||
'review.reasons.inconsistentDetails',
|
||||
],
|
||||
hold: [
|
||||
'review.reasons.awaitingThirdParty',
|
||||
'review.reasons.legalProceedings',
|
||||
'review.reasons.applicantRequest',
|
||||
],
|
||||
escalate: [
|
||||
'review.reasons.aboveAuthority',
|
||||
'review.reasons.policyUnclear',
|
||||
'review.reasons.conflictOfInterest',
|
||||
],
|
||||
};
|
||||
|
||||
export interface DecisionSubmission {
|
||||
reasonCode?: string;
|
||||
reason: string;
|
||||
/** Chosen officer, for Assign and Escalate. */
|
||||
officerId?: string;
|
||||
/** Documents the applicant must fix. Adjustments only. */
|
||||
deficiencies: string[];
|
||||
/** The message that will be sent, after any officer edit. */
|
||||
notificationBody: string;
|
||||
}
|
||||
|
||||
interface DecisionConfirmModalProps {
|
||||
action: ResolvedAction | null;
|
||||
applicantName: string;
|
||||
applicationNumber: string;
|
||||
/** Document keys the officer flagged, for the deficiency checklist. */
|
||||
flaggedDocuments?: string[];
|
||||
/** Populated for Assign and Escalate, which must name a person. */
|
||||
officers?: Array<{ id: string; name: string | null }>;
|
||||
submitting?: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (submission: DecisionSubmission) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One confirmation anatomy for every decision.
|
||||
*
|
||||
* Each decision used to have its own ad-hoc modal — some with a reason, some
|
||||
* without, none showing what the applicant would actually receive. This gives
|
||||
* all of them the same five parts: the consequence in plain language naming
|
||||
* the applicant and application, a reason (required where it matters), the
|
||||
* document deficiency checklist for adjustments, an editable preview of the
|
||||
* message that will be sent, and an explicit warning where the step cannot be
|
||||
* undone.
|
||||
*/
|
||||
export function DecisionConfirmModal({
|
||||
action,
|
||||
applicantName,
|
||||
applicationNumber,
|
||||
flaggedDocuments = [],
|
||||
officers = [],
|
||||
submitting,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DecisionConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [deficiencies, setDeficiencies] = useState<string[]>([]);
|
||||
const [notification, setNotification] = useState('');
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [officerId, setOfficerId] = useState<string | null>(null);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const codes = action ? (REASON_CODES[action.id] ?? []) : [];
|
||||
// `flaggedDocuments` is a fresh array on every parent render, so keying the
|
||||
// reset effect on its identity would wipe the officer's edits continuously.
|
||||
const flaggedKey = flaggedDocuments.join('|');
|
||||
|
||||
// Reset per opening, and seed the message the applicant will receive so the
|
||||
// officer edits real copy rather than composing from nothing.
|
||||
useEffect(() => {
|
||||
if (!action) return;
|
||||
setReasonCode(null);
|
||||
setReason('');
|
||||
setDeficiencies(flaggedDocuments);
|
||||
setAcknowledged(false);
|
||||
setOfficerId(null);
|
||||
setConfirmText('');
|
||||
setNotification(
|
||||
t(`review.notifications.${action.id}`, {
|
||||
applicant: applicantName,
|
||||
number: applicationNumber,
|
||||
defaultValue: t('review.notifications.fallback', {
|
||||
applicant: applicantName,
|
||||
number: applicationNumber,
|
||||
action: t(action.labelKey),
|
||||
defaultValue:
|
||||
'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}, [action, applicantName, applicationNumber, flaggedKey, t]);
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
const needsOfficer = action.id === 'assign' || action.id === 'escalate';
|
||||
const reasonMissing = action.requiresReason && !reason.trim() && !reasonCode;
|
||||
const blocked =
|
||||
reasonMissing ||
|
||||
(needsOfficer && !officerId) ||
|
||||
(action.irreversible && !acknowledged) ||
|
||||
// A destructive action must have the consequence typed out, not just
|
||||
// acknowledged with a tick — it is the last stop before something
|
||||
// irreversible happens to a real operator.
|
||||
(action.tier === 'destructive' && confirmText.trim() !== applicationNumber);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={t(action.labelKey)}
|
||||
size="lg"
|
||||
// Focus returns to the trigger on close, and Esc dismisses.
|
||||
trapFocus
|
||||
returnFocus
|
||||
closeOnEscape
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* 1. What is about to happen, in plain language. */}
|
||||
<Text size="sm">
|
||||
{t(`review.consequences.${action.id}`, {
|
||||
applicant: applicantName,
|
||||
number: applicationNumber,
|
||||
defaultValue: t('review.consequences.fallback', {
|
||||
applicant: applicantName,
|
||||
number: applicationNumber,
|
||||
defaultValue:
|
||||
'This updates application {{number}} for {{applicant}}.',
|
||||
}),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{/* 1b. Who picks it up. */}
|
||||
{needsOfficer && (
|
||||
<Select
|
||||
label={
|
||||
action.id === 'escalate'
|
||||
? t('review.supervisor', 'Supervisor')
|
||||
: t('review.officer', 'Officer')
|
||||
}
|
||||
placeholder={t('review.officerPlaceholder', 'Select who takes this on')}
|
||||
data={officers.map((officer) => ({
|
||||
value: officer.id,
|
||||
label: officer.name ?? officer.id,
|
||||
}))}
|
||||
value={officerId}
|
||||
onChange={setOfficerId}
|
||||
searchable
|
||||
withAsterisk
|
||||
nothingFoundMessage={t('review.noOfficers', 'No officers found')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 2. Reason — coded plus free text. */}
|
||||
{action.requiresReason && (
|
||||
<>
|
||||
{codes.length > 0 && (
|
||||
<Select
|
||||
label={t('review.reasonCode', 'Reason')}
|
||||
placeholder={t('review.reasonCodePlaceholder', 'Select a reason')}
|
||||
data={codes.map((code) => ({ value: code, label: t(code) }))}
|
||||
value={reasonCode}
|
||||
onChange={setReasonCode}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
label={t('review.reasonDetail', 'Details for the applicant')}
|
||||
description={t(
|
||||
'review.reasonDetailHint',
|
||||
'This text is sent to the applicant verbatim.',
|
||||
)}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
withAsterisk={!reasonCode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 3. Deficiency checklist — the applicant sees exactly this list. */}
|
||||
{action.id === 'request-adjustment' && flaggedDocuments.length > 0 && (
|
||||
<Checkbox.Group
|
||||
label={t('review.deficiencies', 'Items the applicant must correct')}
|
||||
description={t(
|
||||
'review.deficienciesHint',
|
||||
'Only the ticked items become editable for the applicant.',
|
||||
)}
|
||||
value={deficiencies}
|
||||
onChange={setDeficiencies}
|
||||
>
|
||||
<Stack gap={4} mt="xs">
|
||||
{flaggedDocuments.map((key) => (
|
||||
<Checkbox key={key} value={key} label={key} />
|
||||
))}
|
||||
</Stack>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
|
||||
{/* 4. Editable preview of the outbound message. */}
|
||||
<Textarea
|
||||
label={t('review.notificationPreview', 'Message to the applicant')}
|
||||
description={t(
|
||||
'review.notificationPreviewHint',
|
||||
'Sent by SMS and email. Edit before confirming if needed.',
|
||||
)}
|
||||
value={notification}
|
||||
onChange={(event) => setNotification(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
{/* 5. Irreversibility, acknowledged explicitly. */}
|
||||
{action.irreversible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'review.irreversibleWarning',
|
||||
'This decision is final and cannot be undone from the backoffice.',
|
||||
)}
|
||||
</Text>
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
|
||||
label={t('review.irreversibleAck', 'I understand this is final')}
|
||||
/>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 5b. Destructive actions require the application number typed out. */}
|
||||
{action.tier === 'destructive' && (
|
||||
<TextInput
|
||||
label={t('review.typeToConfirm', {
|
||||
number: applicationNumber,
|
||||
defaultValue: 'Type {{number}} to confirm',
|
||||
})}
|
||||
value={confirmText}
|
||||
onChange={(event) => setConfirmText(event.currentTarget.value)}
|
||||
placeholder={applicationNumber}
|
||||
error={
|
||||
confirmText && confirmText.trim() !== applicationNumber
|
||||
? t('review.confirmMismatch', 'Does not match')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color={action.color}
|
||||
loading={submitting}
|
||||
disabled={blocked}
|
||||
onClick={() =>
|
||||
onConfirm({
|
||||
reasonCode: reasonCode ?? undefined,
|
||||
officerId: officerId ?? undefined,
|
||||
reason: reason.trim() || (reasonCode ? t(reasonCode) : ''),
|
||||
deficiencies,
|
||||
notificationBody: notification,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileText,
|
||||
IconRotate,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useClearDocumentReviewMutation,
|
||||
useGetDocumentReviewsQuery,
|
||||
useReviewDocumentMutation,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
interface DocumentsTabProps {
|
||||
applicationId: string;
|
||||
attachments: Attachment[];
|
||||
/** From the licence type config, so completeness is measured against rules. */
|
||||
requirements: DocumentRequirement[];
|
||||
/** documentKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, string>;
|
||||
onToggleFlag: (documentKey: string) => void;
|
||||
onFlagRemark: (documentKey: string, remark: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviewer's document workspace.
|
||||
*
|
||||
* Previously a list with View and Download buttons that had no handlers at
|
||||
* all — the officer could see that a document existed but not what was in it,
|
||||
* which makes "approve documents" an act of faith. This previews inline,
|
||||
* measures what is uploaded against what the licence type requires, and lets
|
||||
* each document be flagged with its own reason.
|
||||
*/
|
||||
export function DocumentsTab({
|
||||
applicationId,
|
||||
attachments,
|
||||
requirements,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
}: DocumentsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
||||
|
||||
// Verdicts are persisted per document, so an accept survives a reload and
|
||||
// is visible to whoever picks the application up next.
|
||||
const { data: reviews = [] } = useGetDocumentReviewsQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const [reviewDocument, { isLoading: saving }] = useReviewDocumentMutation();
|
||||
const [clearReview] = useClearDocumentReviewMutation();
|
||||
|
||||
const verdictFor = (documentKey: string) =>
|
||||
reviews.find((review) => review.documentKey === documentKey);
|
||||
|
||||
async function decide(
|
||||
documentKey: string,
|
||||
decision: 'ACCEPTED' | 'REJECTED',
|
||||
attachmentId?: string,
|
||||
) {
|
||||
const reason = rejecting[documentKey]?.trim();
|
||||
if (decision === 'REJECTED' && !reason) {
|
||||
// The applicant is shown this verbatim, so refuse to send an empty one.
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.documents.reasonRequired', 'A reason is required'),
|
||||
message: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reviewDocument({
|
||||
id: applicationId,
|
||||
documentKey,
|
||||
decision,
|
||||
reason: decision === 'REJECTED' ? reason : undefined,
|
||||
attachmentId,
|
||||
}).unwrap();
|
||||
setRejecting((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[documentKey];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.documents.saveFailed', 'Could not save the verdict'),
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
||||
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
|
||||
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
||||
const completeness = mandatory.length
|
||||
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
|
||||
: 100;
|
||||
|
||||
const previewFile = preview?.files?.[0];
|
||||
const isImage = previewFile?.mimeType?.startsWith('image/');
|
||||
const isPdf = previewFile?.mimeType === 'application/pdf';
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Completeness against the licence type's own requirement list. */}
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{t('review.documents.completeness', 'Required documents')}
|
||||
</Text>
|
||||
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
|
||||
{mandatory.length - missing.length}/{mandatory.length}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={completeness}
|
||||
color={missing.length ? 'orange' : 'teal'}
|
||||
aria-label={t('review.documents.completenessLabel', {
|
||||
value: completeness,
|
||||
defaultValue: '{{value}}% of required documents uploaded',
|
||||
})}
|
||||
/>
|
||||
{missing.length > 0 && (
|
||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||
<Text size="sm">
|
||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
||||
{missing.map((r) => r.name.en ?? r.key).join(', ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{attachments.map((attachment) => {
|
||||
const file = attachment.files?.[0];
|
||||
const flagged = attachment.documentKey in flags;
|
||||
const verdict = verdictFor(attachment.documentKey);
|
||||
const pendingReject = attachment.documentKey in rejecting;
|
||||
return (
|
||||
<Paper withBorder p="md" key={attachment.id}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IconFileText size={20} stroke={1.6} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{attachment.documentKey}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
||||
</Text>
|
||||
</div>
|
||||
{verdict && (
|
||||
<Tooltip
|
||||
label={
|
||||
verdict.reason ??
|
||||
t('review.documents.reviewedBy', {
|
||||
name: verdict.reviewedByName ?? '—',
|
||||
defaultValue: 'Reviewed by {{name}}',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{flagged && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.documents.flagged', 'Correction requested')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.preview', 'Preview')
|
||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
disabled={!file?.url}
|
||||
onClick={() => setPreview(attachment)}
|
||||
>
|
||||
{t('review.documents.view', 'View')}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.download', 'Download')
|
||||
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={!file?.url}
|
||||
component="a"
|
||||
href={file?.url}
|
||||
download={file?.originalName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t('review.documents.download', 'Download')}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* Accept / Reject are the officer's own record of having
|
||||
checked the file, persisted independently of any
|
||||
adjustment round. */}
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.accept', 'Accept')
|
||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<ActionIcon
|
||||
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
|
||||
color="teal"
|
||||
loading={saving}
|
||||
disabled={!file?.url}
|
||||
aria-label={t('review.documents.accept', 'Accept')}
|
||||
onClick={() =>
|
||||
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
|
||||
}
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
file?.url
|
||||
? t('review.documents.reject', 'Reject')
|
||||
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<ActionIcon
|
||||
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
|
||||
color="red"
|
||||
disabled={!file?.url}
|
||||
aria-label={t('review.documents.reject', 'Reject')}
|
||||
onClick={() =>
|
||||
setRejecting((prev) => ({
|
||||
...prev,
|
||||
[attachment.documentKey]: verdict?.reason ?? '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{verdict && (
|
||||
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t('review.documents.clear', 'Clear verdict')}
|
||||
onClick={() =>
|
||||
clearReview({
|
||||
id: applicationId,
|
||||
documentKey: attachment.documentKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconRotate size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={flagged}
|
||||
onChange={() => onToggleFlag(attachment.documentKey)}
|
||||
label={t('review.documents.includeInAdjustment', 'Send back')}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{pendingReject && (
|
||||
<Group mt="sm" gap="xs" align="flex-start" wrap="nowrap">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
autoFocus
|
||||
placeholder={t(
|
||||
'review.documents.rejectReason',
|
||||
'Why must this document be corrected?',
|
||||
)}
|
||||
value={rejecting[attachment.documentKey]}
|
||||
onChange={(e) =>
|
||||
setRejecting((prev) => ({
|
||||
...prev,
|
||||
[attachment.documentKey]: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
loading={saving}
|
||||
disabled={!rejecting[attachment.documentKey]?.trim()}
|
||||
onClick={() =>
|
||||
decide(attachment.documentKey, 'REJECTED', attachment.id)
|
||||
}
|
||||
>
|
||||
{t('review.documents.confirmReject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{flagged && (
|
||||
<TextInput
|
||||
mt="sm"
|
||||
size="xs"
|
||||
placeholder={t(
|
||||
'review.documents.adjustmentNote',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
value={flags[attachment.documentKey]}
|
||||
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
|
||||
error={
|
||||
flags[attachment.documentKey].trim()
|
||||
? undefined
|
||||
: t('review.documents.reasonRequired', 'A reason is required')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
<Drawer
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
position="right"
|
||||
size="xl"
|
||||
title={preview?.documentKey}
|
||||
// Focus is trapped and returned so keyboard users are not dropped at
|
||||
// the top of the page when the drawer closes.
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
{previewFile?.url ? (
|
||||
isPdf ? (
|
||||
<iframe
|
||||
src={previewFile.url}
|
||||
title={preview?.documentKey ?? 'document'}
|
||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={previewFile.url}
|
||||
alt={preview?.documentKey ?? 'document'}
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
// Anything the browser will not render inline still gets a way out.
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'review.documents.noInlinePreview',
|
||||
'This file type cannot be previewed in the browser.',
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href={previewFile.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
>
|
||||
{t('review.documents.downloadShort', 'Download')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
) : null}
|
||||
</Drawer>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
|
||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
||||
|
||||
/**
|
||||
* Where an action is rendered. One tier per action, decided here rather than
|
||||
* by whoever happens to be laying out the page — that is what produced buttons
|
||||
* scattered down the right-hand column with no ordering principle.
|
||||
*/
|
||||
export type ActionTier =
|
||||
/** Approve / Request Adjustment / Reject. Decision Bar, right. Max three. */
|
||||
| 'primary'
|
||||
/** Claim, Assign, Escalate, Hold, Return. Decision Bar, left. */
|
||||
| 'workflow'
|
||||
/** Print, Export, Certificate, Audit, Copy Link. Overflow menu. */
|
||||
| 'secondary'
|
||||
/** Void, Revoke, Cancel. Overflow menu, separated, red, typed confirm. */
|
||||
| 'destructive';
|
||||
|
||||
export type ActionId =
|
||||
| 'claim'
|
||||
| 'assign'
|
||||
| 'escalate'
|
||||
| 'hold'
|
||||
| 'resume'
|
||||
| 'complete-review'
|
||||
| 'approve-documents'
|
||||
| 'schedule-inspection'
|
||||
| 'record-inspection'
|
||||
| 'final-approve'
|
||||
| 'request-adjustment'
|
||||
| 'reject'
|
||||
| 'confirm-payment'
|
||||
| 'print'
|
||||
| 'copy-link'
|
||||
| 'download-documents'
|
||||
| 'generate-certificate'
|
||||
| 'audit-trail';
|
||||
|
||||
export interface ActionDefinition {
|
||||
id: ActionId;
|
||||
tier: ActionTier;
|
||||
labelKey: string;
|
||||
/** Statuses the action can be fired from. Mirrors the API transition table. */
|
||||
from?: LicenseStatus[];
|
||||
/** Any one of these authorises it. Omitted means no permission needed. */
|
||||
permissions?: string[];
|
||||
/** Only one primary action is ever filled; everything else is light. */
|
||||
emphasis?: 'filled' | 'light' | 'subtle';
|
||||
color?: string;
|
||||
/** Requires a typed reason before it will submit. */
|
||||
requiresReason?: boolean;
|
||||
/** Cannot be undone — the confirmation says so explicitly. */
|
||||
irreversible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every action an officer can take, in one place.
|
||||
*
|
||||
* Actions absent from this list are absent because the API has no endpoint for
|
||||
* them. Void, Revoke and Cancel are the notable gaps: `SUSPEND_LICENSE` and
|
||||
* `CANCEL_LICENSE` permissions exist, but no route does either, so rendering
|
||||
* them would be a button that cannot work.
|
||||
*/
|
||||
export const ACTIONS: ActionDefinition[] = [
|
||||
// ------------------------------------------------------------- workflow
|
||||
{
|
||||
id: 'claim',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.claim',
|
||||
from: ['SUBMITTED'],
|
||||
permissions: ['can:claim:license-application'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
{
|
||||
id: 'assign',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.assign',
|
||||
from: [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'ON_HOLD',
|
||||
],
|
||||
permissions: ['can:assign:license-application'],
|
||||
emphasis: 'subtle',
|
||||
},
|
||||
{
|
||||
id: 'escalate',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.escalate',
|
||||
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
|
||||
permissions: ['can:escalate:license-application'],
|
||||
emphasis: 'subtle',
|
||||
requiresReason: true,
|
||||
},
|
||||
{
|
||||
id: 'hold',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.hold',
|
||||
from: [
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
],
|
||||
permissions: ['can:hold:license-application'],
|
||||
emphasis: 'subtle',
|
||||
requiresReason: true,
|
||||
},
|
||||
{
|
||||
id: 'resume',
|
||||
tier: 'workflow',
|
||||
labelKey: 'review.actions.resume',
|
||||
from: ['ON_HOLD'],
|
||||
permissions: ['can:hold:license-application'],
|
||||
emphasis: 'light',
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- primary
|
||||
{
|
||||
id: 'complete-review',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.completeReview',
|
||||
from: ['UNDER_REVIEW'],
|
||||
permissions: [
|
||||
'can:review:license-application',
|
||||
'can:evaluate:license-application',
|
||||
],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'approve-documents',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.approveDocuments',
|
||||
from: ['UNDER_EVALUATION'],
|
||||
permissions: ['can:evaluate:license-application'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'schedule-inspection',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.scheduleInspection',
|
||||
from: ['INSPECTION_PENDING'],
|
||||
permissions: ['can:create:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'record-inspection',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.recordInspection',
|
||||
from: ['INSPECTION_PENDING'],
|
||||
permissions: ['can:update:inspection'],
|
||||
emphasis: 'filled',
|
||||
},
|
||||
{
|
||||
id: 'final-approve',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.finalApprove',
|
||||
from: ['INSPECTION_COMPLETED', 'UNDER_EVALUATION'],
|
||||
permissions: ['can:approve:license-application'],
|
||||
emphasis: 'filled',
|
||||
color: 'teal',
|
||||
irreversible: true,
|
||||
},
|
||||
{
|
||||
id: 'request-adjustment',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.requestAdjustment',
|
||||
from: ['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_COMPLETED'],
|
||||
permissions: ['can:request-adjustment:license-application'],
|
||||
emphasis: 'light',
|
||||
color: 'orange',
|
||||
requiresReason: true,
|
||||
},
|
||||
{
|
||||
id: 'reject',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.reject',
|
||||
from: [
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
],
|
||||
permissions: ['can:reject:license-application'],
|
||||
emphasis: 'light',
|
||||
color: 'red',
|
||||
requiresReason: true,
|
||||
irreversible: true,
|
||||
},
|
||||
{
|
||||
id: 'confirm-payment',
|
||||
tier: 'primary',
|
||||
labelKey: 'review.actions.confirmPayment',
|
||||
from: ['PAID'],
|
||||
permissions: ['can:confirm:license-payment'],
|
||||
emphasis: 'filled',
|
||||
color: 'teal',
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------ secondary
|
||||
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
|
||||
{ id: 'copy-link', tier: 'secondary', labelKey: 'review.actions.copyLink' },
|
||||
{
|
||||
id: 'download-documents',
|
||||
tier: 'secondary',
|
||||
labelKey: 'review.actions.downloadDocuments',
|
||||
},
|
||||
{
|
||||
id: 'generate-certificate',
|
||||
tier: 'secondary',
|
||||
labelKey: 'review.actions.generateCertificate',
|
||||
from: ['CERTIFICATE_ISSUED'],
|
||||
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
|
||||
},
|
||||
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
|
||||
];
|
||||
|
||||
export interface ResolvedAction extends ActionDefinition {
|
||||
/** False when the officer can see it but cannot fire it right now. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Why it is disabled, already translated. Never null when `enabled` is
|
||||
* false — a greyed-out control with no explanation is the thing this whole
|
||||
* model exists to prevent.
|
||||
*/
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export interface ResolveContext {
|
||||
detail: ApplicationDetail;
|
||||
currentUserId: string;
|
||||
can: (permissions?: string[]) => boolean;
|
||||
/** Translated strings for the disabled explanations. */
|
||||
reasons: {
|
||||
wrongStatus: string;
|
||||
notAssigned: string;
|
||||
noPermission: string;
|
||||
needsFlags: string;
|
||||
needsCapital: string;
|
||||
needsInspection: string;
|
||||
};
|
||||
/** Number of sections/documents the officer has flagged for correction. */
|
||||
flaggedCount: number;
|
||||
/** True when an inspection is scheduled and awaiting a result. */
|
||||
hasPendingInspection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which actions to render, and for each, whether it can fire and why not.
|
||||
*
|
||||
* Actions the user has no permission for are dropped entirely; actions that
|
||||
* are merely unavailable right now are kept and disabled with a reason, so the
|
||||
* officer can see what the next step would be rather than wondering whether
|
||||
* the screen is broken.
|
||||
*/
|
||||
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
const { detail, currentUserId, can, reasons } = ctx;
|
||||
const app = detail.application;
|
||||
|
||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||
(action) => {
|
||||
// Status-scoped actions vanish outside their stage rather than piling up
|
||||
// as a column of permanently dead buttons.
|
||||
if (action.from && !action.from.includes(app.status)) return [];
|
||||
|
||||
// Scheduling and recording are the same slot at the same status; which
|
||||
// one applies depends on whether an inspection is already booked.
|
||||
if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return [];
|
||||
if (action.id === 'record-inspection' && !ctx.hasPendingInspection) return [];
|
||||
|
||||
const disabled = (reason: string): ResolvedAction => ({
|
||||
...action,
|
||||
enabled: false,
|
||||
disabledReason: reason,
|
||||
});
|
||||
|
||||
// Decisions belong to whoever holds the application.
|
||||
const needsOwnership =
|
||||
action.tier === 'primary' && action.id !== 'confirm-payment';
|
||||
if (
|
||||
needsOwnership &&
|
||||
app.assignedOfficerId &&
|
||||
app.assignedOfficerId !== currentUserId
|
||||
) {
|
||||
return disabled(reasons.notAssigned);
|
||||
}
|
||||
|
||||
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
|
||||
return disabled(reasons.needsFlags);
|
||||
}
|
||||
|
||||
if (action.id === 'final-approve') {
|
||||
const threshold = app.licenseType?.capitalThreshold;
|
||||
const needsCapital = threshold != null && Number(threshold) > 0;
|
||||
if (needsCapital && app.capitalAmountVerified == null) {
|
||||
return disabled(reasons.needsCapital);
|
||||
}
|
||||
if (
|
||||
app.licenseType?.inspectionRequired &&
|
||||
app.status !== 'INSPECTION_COMPLETED'
|
||||
) {
|
||||
return disabled(reasons.needsInspection);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...action, enabled: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconFileDescription,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
type Icon,
|
||||
} from '@tabler/icons-react';
|
||||
import type { LicenseApplication, LicenseType } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Presentation-only metadata per licence type.
|
||||
*
|
||||
* Deliberately thin. Everything that actually varies between licence types —
|
||||
* fees, capital threshold, validity, whether an inspection is required,
|
||||
* whether a certificate is issued, the form schema, the document and staff
|
||||
* requirements — already lives in the `license_types` table and arrives from
|
||||
* `GET /license-types`. The API entity states the rule outright: onboarding a
|
||||
* new type is meant to be a seed or admin change, not a code change
|
||||
* (BR-MTO-020).
|
||||
*
|
||||
* Duplicating any of that here would fork the source of truth and mean a new
|
||||
* licence type silently rendered with another type's rules. So this file holds
|
||||
* only what the database has no opinion about: which icon to draw, and the
|
||||
* order review tabs appear in.
|
||||
*/
|
||||
export interface LicenseTypePresentation {
|
||||
/** Matches `LicenseType.key`. */
|
||||
key: string;
|
||||
icon: Icon;
|
||||
/** Review tabs, in order. Tabs with no data do not render. */
|
||||
detailSections: DetailSection[];
|
||||
}
|
||||
|
||||
export type DetailSection =
|
||||
| 'overview'
|
||||
| 'company'
|
||||
| 'financials'
|
||||
| 'documents'
|
||||
| 'staff'
|
||||
| 'inspection';
|
||||
|
||||
const DEFAULT_SECTIONS: DetailSection[] = [
|
||||
'overview',
|
||||
'company',
|
||||
'financials',
|
||||
'documents',
|
||||
'staff',
|
||||
'inspection',
|
||||
];
|
||||
|
||||
const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
FREIGHT_FORWARDER: {
|
||||
key: 'FREIGHT_FORWARDER',
|
||||
icon: IconTruck,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
SHIPPING_AGENT: {
|
||||
key: 'SHIPPING_AGENT',
|
||||
icon: IconShip,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
COMBINED_SA_FF: {
|
||||
key: 'COMBINED_SA_FF',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
JOINT_INVESTOR: {
|
||||
key: 'JOINT_INVESTOR',
|
||||
icon: IconUsers,
|
||||
// Terminates at COMPLETED with no payment and no certificate, and the
|
||||
// workflow skips inspection for it.
|
||||
detailSections: ['overview', 'company', 'financials', 'documents', 'staff'],
|
||||
},
|
||||
MULTIMODAL_TRANSPORT_OPERATOR: {
|
||||
key: 'MULTIMODAL_TRANSPORT_OPERATOR',
|
||||
icon: IconAnchor,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
};
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
export function presentationFor(key: string | undefined): LicenseTypePresentation {
|
||||
return (
|
||||
(key && PRESENTATION[key]) || {
|
||||
key: key ?? 'UNKNOWN',
|
||||
icon: IconFileDescription,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const LICENSE_TYPE_KEYS = Object.keys(PRESENTATION);
|
||||
|
||||
// ---------------------------------------------------------------- eligibility
|
||||
|
||||
export interface EligibilityRule {
|
||||
id: string;
|
||||
/** Plain-language statement of the rule, already interpolated. */
|
||||
label: string;
|
||||
/** What the application actually declares/verifies, formatted. */
|
||||
actual: string;
|
||||
status: 'pass' | 'fail' | 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the licence type's configured thresholds into a checked list.
|
||||
*
|
||||
* The capital threshold used to be applied invisibly — the officer saw a
|
||||
* disabled approve button and had to know why. Rendering it as an explicit
|
||||
* pass/fail line means the rule, the figure it was checked against, and the
|
||||
* outcome are all on screen.
|
||||
*/
|
||||
export function evaluateEligibility(
|
||||
application: LicenseApplication,
|
||||
licenseType: LicenseType | undefined,
|
||||
locale: string,
|
||||
): EligibilityRule[] {
|
||||
const rules: EligibilityRule[] = [];
|
||||
|
||||
const threshold =
|
||||
licenseType?.capitalThreshold == null
|
||||
? undefined
|
||||
: Number(licenseType.capitalThreshold);
|
||||
|
||||
if (threshold !== undefined && !Number.isNaN(threshold)) {
|
||||
const verified =
|
||||
application.capitalAmountVerified == null
|
||||
? undefined
|
||||
: Number(application.capitalAmountVerified);
|
||||
const declared =
|
||||
application.capitalAmountDeclared == null
|
||||
? undefined
|
||||
: Number(application.capitalAmountDeclared);
|
||||
const effective = verified ?? declared;
|
||||
const currency = licenseType?.feeCurrency ?? 'ETB';
|
||||
const format = (value: number) =>
|
||||
`${value.toLocaleString(locale)} ${currency}`;
|
||||
|
||||
rules.push({
|
||||
id: 'capital-threshold',
|
||||
label: `Paid-up capital ≥ ${format(threshold)}`,
|
||||
actual:
|
||||
effective === undefined
|
||||
? 'Not recorded'
|
||||
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
|
||||
// An unverified declaration is not evidence, so it reads as unknown
|
||||
// rather than as a pass the officer never actually made.
|
||||
status:
|
||||
effective === undefined || verified === undefined
|
||||
? 'unknown'
|
||||
: effective >= threshold
|
||||
? 'pass'
|
||||
: 'fail',
|
||||
});
|
||||
}
|
||||
|
||||
if (licenseType?.inspectionRequired) {
|
||||
const inspected = [
|
||||
'INSPECTION_COMPLETED',
|
||||
'APPROVED',
|
||||
'PAYMENT_PENDING',
|
||||
'PAID',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
].includes(application.status);
|
||||
rules.push({
|
||||
id: 'inspection',
|
||||
label: 'Physical inspection completed',
|
||||
actual: inspected ? 'Recorded' : 'Not yet recorded',
|
||||
status: inspected ? 'pass' : 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
72
apps/backoffice/src/app/features/license-review/export.ts
Normal file
72
apps/backoffice/src/app/features/license-review/export.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
|
||||
import { computeSla } from './sla';
|
||||
|
||||
/**
|
||||
* Escapes one CSV field.
|
||||
*
|
||||
* Company names routinely contain commas, and remarks contain quotes and
|
||||
* newlines — unescaped, either one shifts every later column on the row.
|
||||
*/
|
||||
function csvCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = String(value);
|
||||
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
const COLUMNS: Array<{
|
||||
header: string;
|
||||
value: (app: LicenseApplication, locale: string) => unknown;
|
||||
}> = [
|
||||
{ header: 'Application #', value: (a) => a.applicationNumber },
|
||||
{ header: 'Company', value: (a) => a.companyName },
|
||||
{ header: 'Trade name', value: (a) => a.tradeName },
|
||||
{ header: 'TIN', value: (a) => a.tinNumber },
|
||||
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
|
||||
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
|
||||
{ header: 'Kind', value: (a) => a.kind },
|
||||
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
||||
{
|
||||
header: 'Submitted',
|
||||
value: (a, locale) =>
|
||||
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
|
||||
},
|
||||
{
|
||||
header: 'Decided',
|
||||
value: (a, locale) =>
|
||||
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
|
||||
},
|
||||
{ header: 'SLA', value: (a) => computeSla(a).label },
|
||||
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
|
||||
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
|
||||
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exports exactly the rows passed in.
|
||||
*
|
||||
* Takes the already-filtered, already-paged list rather than re-querying, so
|
||||
* what lands in the file is what the officer was looking at. Note this means
|
||||
* an export covers the current page — exporting a whole filtered result set
|
||||
* would need a server-side export endpoint, which does not exist.
|
||||
*/
|
||||
export function exportApplicationsCsv(
|
||||
applications: LicenseApplication[],
|
||||
locale: string,
|
||||
filename = `licence-applications-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
): void {
|
||||
const header = COLUMNS.map((column) => csvCell(column.header)).join(',');
|
||||
const rows = applications.map((app) =>
|
||||
COLUMNS.map((column) => csvCell(column.value(app, locale))).join(','),
|
||||
);
|
||||
// BOM so Excel opens Amharic and other non-ASCII content as UTF-8.
|
||||
const blob = new Blob(['', [header, ...rows].join('\r\n')], {
|
||||
type: 'text/csv;charset=utf-8;',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Pagination,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useClaimApplicationMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { computeSla } from '../sla';
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
filterFromSearchParams,
|
||||
readLastView,
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
type SavedViewId,
|
||||
} from '../queue-views';
|
||||
import { exportApplicationsCsv } from '../export';
|
||||
import { setDensity } from '../../../store/preferences.slice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'ON_HOLD',
|
||||
'APPROVED',
|
||||
'PAYMENT_PENDING',
|
||||
'PAID',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
/**
|
||||
* The officer work pool.
|
||||
*
|
||||
* Saved views across the top, facets serialised into the URL so a filtered
|
||||
* queue can be shared, and server-side pagination — the previous version
|
||||
* rendered `data.items` unpaged, which was fine at demo volumes and would have
|
||||
* stopped being fine somewhere in the hundreds.
|
||||
*/
|
||||
export function LicenseQueuePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { typeCode } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const dispatch = useAppDispatch();
|
||||
const density = useAppSelector((state) => state.preferences.density);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() => (searchParams.get('view') as SavedViewId) || readLastView(),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const urlFilter = useMemo(
|
||||
() => filterFromSearchParams(searchParams),
|
||||
[searchParams],
|
||||
);
|
||||
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
|
||||
|
||||
const { data: licenseTypes } = useGetLicenseTypesQuery();
|
||||
const { data: counts } = useGetQueueCountsQuery();
|
||||
|
||||
// A `/licence-review/type/:typeCode` deep link pins the type facet.
|
||||
const pinnedTypeId = useMemo(() => {
|
||||
if (!typeCode) return undefined;
|
||||
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
|
||||
}, [typeCode, licenseTypes]);
|
||||
|
||||
const filter: QueueFilter = useMemo(
|
||||
() => ({
|
||||
...activeView.filter,
|
||||
...urlFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||||
take: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
}),
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
|
||||
);
|
||||
|
||||
// One query per source; the two inactive ones are skipped, so switching
|
||||
// views costs a single request rather than keeping three in flight.
|
||||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
||||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
||||
const active =
|
||||
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
|
||||
|
||||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||||
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
|
||||
|
||||
/**
|
||||
* Exports every row the filter matches, not just the page on screen.
|
||||
* The server caps the result set and reports when it did, so a truncated
|
||||
* export says so instead of quietly being wrong.
|
||||
*/
|
||||
async function handleExport() {
|
||||
try {
|
||||
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
|
||||
exportApplicationsCsv(result.items, i18n.language);
|
||||
if (result.truncated) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: t('queue.exportTruncated', 'Export truncated'),
|
||||
message: t('queue.exportTruncatedBody', {
|
||||
exported: result.items.length,
|
||||
total: result.total,
|
||||
defaultValue:
|
||||
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.exportFailed', 'Export failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const items = active.data?.items ?? [];
|
||||
const total = active.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||
setSearchParams(
|
||||
searchParamsFromFilter({ ...urlFilter, ...next }, nextView, nextPage),
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[urlFilter, setSearchParams],
|
||||
);
|
||||
|
||||
const changeView = (next: SavedViewId) => {
|
||||
setView(next);
|
||||
writeLastView(next);
|
||||
setPage(1);
|
||||
setSelected([]);
|
||||
updateUrl({}, next, 1);
|
||||
};
|
||||
|
||||
const setFacet = (next: Partial<QueueFilter>) => {
|
||||
setPage(1);
|
||||
updateUrl(next, view, 1);
|
||||
};
|
||||
|
||||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
||||
const dir =
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
||||
setFacet({ sortBy: field, sortDir: dir });
|
||||
};
|
||||
|
||||
async function handleClaim(id: string) {
|
||||
try {
|
||||
await claim(id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('queue.claimed', 'Claimed'),
|
||||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||||
});
|
||||
changeView('mine');
|
||||
} catch (err) {
|
||||
// A 409 means another officer got there first — refresh so the queue
|
||||
// stops showing work that is no longer available.
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.claimFailed', 'Could not claim'),
|
||||
message: extractErrorMessage(
|
||||
err,
|
||||
t('queue.claimRace', 'Another officer already claimed it.'),
|
||||
),
|
||||
});
|
||||
active.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkClaim() {
|
||||
const results = await Promise.allSettled(
|
||||
selected.map((id) => claim(id).unwrap()),
|
||||
);
|
||||
const claimed = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const lost = results.length - claimed;
|
||||
notifications.show({
|
||||
color: lost ? 'yellow' : 'teal',
|
||||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
||||
// Partial success is the normal case in a shared queue, so it is
|
||||
// reported rather than swallowed or treated as total failure.
|
||||
message: lost
|
||||
? t('queue.bulkClaimPartial', {
|
||||
count: lost,
|
||||
defaultValue: '{{count}} were already taken by another officer.',
|
||||
})
|
||||
: '',
|
||||
});
|
||||
setSelected([]);
|
||||
active.refetch();
|
||||
}
|
||||
|
||||
const cursorRow = items[cursor];
|
||||
useQueueKeyboard({
|
||||
enabled: !helpOpen,
|
||||
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||
onClaim: () => {
|
||||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||||
// rather than an error the officer has to read.
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
});
|
||||
|
||||
const allSelected = items.length > 0 && selected.length === items.length;
|
||||
const sortIcon =
|
||||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||||
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||||
data={[
|
||||
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||||
{ label: t('queue.compact', 'Compact'), value: 'compact' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={total === 0}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
key={savedView.id}
|
||||
value={savedView.id}
|
||||
rightSection={
|
||||
counts?.[savedView.countKey] ? (
|
||||
<Badge size="xs" variant="light" circle>
|
||||
{counts[savedView.countKey]}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t(savedView.labelKey)}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{/* Facets — every one of these is reflected in the URL. */}
|
||||
<Paper withBorder p="sm" mb="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('queue.search', 'Search')}
|
||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('queue.status', 'Status')}
|
||||
placeholder={t('queue.anyStatus', 'Any')}
|
||||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
value={urlFilter.status ?? []}
|
||||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||||
clearable
|
||||
w={240}
|
||||
/>
|
||||
{!typeCode && (
|
||||
<Select
|
||||
label={t('queue.type', 'Licence type')}
|
||||
placeholder={t('queue.anyType', 'Any')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name.en ?? type.key,
|
||||
}))}
|
||||
value={urlFilter.licenseTypeId ?? null}
|
||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
{t('queue.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{active.isLoading ? (
|
||||
// Skeleton rows match the real table, so the layout does not jump
|
||||
// when data lands.
|
||||
<Stack gap={0} p="md">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height={44} mb="xs" radius="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
) : active.isError ? (
|
||||
<ErrorState
|
||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||||
description={extractErrorMessage(active.error)}
|
||||
onRetry={() => active.refetch()}
|
||||
icon={IconAlertCircle}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
title={
|
||||
hasFacets
|
||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||
: t('queue.empty', 'Nothing waiting here')
|
||||
}
|
||||
description={
|
||||
hasFacets
|
||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||||
}
|
||||
action={
|
||||
hasFacets
|
||||
? {
|
||||
label: t('queue.clearFilters', 'Clear'),
|
||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectAll', 'Select all')}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.number', 'App #')}
|
||||
field="applicationNumber"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.company', 'Company')}
|
||||
field="companyName"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
|
||||
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.statusCol', 'Status')}
|
||||
field="status"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.submitted', 'Submitted')}
|
||||
field="submittedAt"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app, index) => (
|
||||
<QueueRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
focused={index === cursor}
|
||||
selected={selected.includes(app.id)}
|
||||
claiming={claiming}
|
||||
locale={i18n.language}
|
||||
onSelect={(checked) =>
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
|
||||
)
|
||||
}
|
||||
onClaim={() => handleClaim(app.id)}
|
||||
onOpen={() => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between" p="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
{KEYBOARD_SHORTCUTS.map((shortcut) => (
|
||||
<Group key={shortcut.keys} justify="space-between">
|
||||
<Text size="sm">{t(shortcut.labelKey)}</Text>
|
||||
<Kbd>{shortcut.keys}</Kbd>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk bar. Floating, with the count stated so the scope of the action
|
||||
is never ambiguous. */}
|
||||
{selected.length > 0 && (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="sm"
|
||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('queue.selectedCount', {
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() =>
|
||||
exportApplicationsCsv(
|
||||
items.filter((a) => selected.includes(a.id)),
|
||||
i18n.language,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
</Button>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t('queue.bulkClaim', {
|
||||
count: selected.length,
|
||||
defaultValue: 'Claim {{count}}',
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTh({
|
||||
label,
|
||||
field,
|
||||
current,
|
||||
icon,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
field: NonNullable<QueueFilter['sortBy']>;
|
||||
current?: QueueFilter['sortBy'];
|
||||
icon: React.ReactNode;
|
||||
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Th>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{current === field && icon}
|
||||
</Group>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
app,
|
||||
selected,
|
||||
focused,
|
||||
claiming,
|
||||
locale,
|
||||
onSelect,
|
||||
onClaim,
|
||||
onOpen,
|
||||
}: {
|
||||
app: LicenseApplication;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
claiming: boolean;
|
||||
locale: string;
|
||||
onSelect: (checked: boolean) => void;
|
||||
onClaim: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sla = computeSla(app);
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
// Keyboard cursor. Marked with a left border rather than a background so
|
||||
// it stays distinguishable from row selection and from hover.
|
||||
style={
|
||||
focused
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* Colour is never the only signal — the label says the same thing. */}
|
||||
<Tooltip label={sla.tooltip} withArrow>
|
||||
<Badge color={sla.color} variant="light" size="sm">
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={claiming} onClick={onClaim}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={onOpen}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseQueuePage;
|
||||
@@ -0,0 +1,892 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconQuestionMark,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
useConfirmPaymentMutation,
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
useGetInspectionsQuery,
|
||||
useGetAssignableOfficersQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useHoldApplicationMutation,
|
||||
useRecordInspectionResultMutation,
|
||||
useRejectApplicationMutation,
|
||||
useRequestAdjustmentMutation,
|
||||
useResumeApplicationMutation,
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
} from '@ema-platform/api';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { DecisionBar } from '../components/DecisionBar';
|
||||
import {
|
||||
DecisionConfirmModal,
|
||||
type DecisionSubmission,
|
||||
} from '../components/DecisionConfirmModal';
|
||||
import { ActivityRail } from '../components/ActivityRail';
|
||||
import { DocumentsTab } from '../components/DocumentsTab';
|
||||
import { computeSla } from '../sla';
|
||||
import { evaluateEligibility, presentationFor } from '../config/license-types';
|
||||
import { resolveActions, type ActionId, type ResolvedAction } from '../config/actions';
|
||||
|
||||
type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
||||
|
||||
/**
|
||||
* The officer's review workspace.
|
||||
*
|
||||
* Three zones: a sticky left rail carrying the summary and eligibility, a
|
||||
* centre column of tabs driven by the licence type's sections, and a
|
||||
* collapsible activity trail on the right. Every decision goes through the
|
||||
* Decision Bar pinned to the bottom, so an officer can act from any scroll
|
||||
* position instead of scrolling back to a column of buttons.
|
||||
*/
|
||||
export function LicenseReviewPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { id = '' } = useParams();
|
||||
const { can } = usePermissions();
|
||||
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useGetApplicationForReviewQuery(id, {
|
||||
skip: !id,
|
||||
});
|
||||
const { data: inspections = [], refetch: refetchInspections } = useGetInspectionsQuery(id, {
|
||||
skip: !id,
|
||||
});
|
||||
const { data: requirements } = useGetLicenseTypeRequirementsQuery(
|
||||
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
|
||||
{ skip: !data?.application.licenseTypeId },
|
||||
);
|
||||
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
const [approveDocuments] = useApproveDocumentsMutation();
|
||||
const [finalApprove] = useFinalApproveMutation();
|
||||
const [rejectApplication] = useRejectApplicationMutation();
|
||||
const [scheduleInspection] = useScheduleInspectionMutation();
|
||||
const [recordResult] = useRecordInspectionResultMutation();
|
||||
const [confirmPayment] = useConfirmPaymentMutation();
|
||||
const [holdApplication] = useHoldApplicationMutation();
|
||||
const [resumeApplication] = useResumeApplicationMutation();
|
||||
const [escalateApplication] = useEscalateApplicationMutation();
|
||||
const [assignApplication] = useAssignApplicationMutation();
|
||||
// Real officer list, so Assign and Escalate name a person instead of
|
||||
// silently reassigning to whoever already held the application.
|
||||
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
||||
|
||||
const [flags, setFlags] = useState<FlagMap>({});
|
||||
const [capital, setCapital] = useState<number | undefined>();
|
||||
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null);
|
||||
const [busyAction, setBusyAction] = useState<ActionId | null>(null);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
const [inspectionOpen, setInspectionOpen] = useState(false);
|
||||
const [inspectionDate, setInspectionDate] = useState('');
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [findings, setFindings] = useState('');
|
||||
|
||||
// Prefill from whatever is recorded, else the declared figure, so the officer
|
||||
// confirms a number rather than retyping it. Runs before the early return
|
||||
// below because hooks must be called unconditionally.
|
||||
const seeded = useRef(false);
|
||||
const loadedApp = data?.application;
|
||||
useEffect(() => {
|
||||
if (seeded.current || !loadedApp) return;
|
||||
const existing = loadedApp.capitalAmountVerified ?? loadedApp.capitalAmountDeclared;
|
||||
if (existing != null) setCapital(Number(existing));
|
||||
seeded.current = true;
|
||||
}, [loadedApp]);
|
||||
|
||||
const toggleFlag = useCallback((targetType: RemarkTargetType, key: string) => {
|
||||
setFlags((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { targetType, remark: '' };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const documentFlags = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const [key, flag] of Object.entries(flags)) {
|
||||
if (flag.targetType === 'DOCUMENT') map[key] = flag.remark;
|
||||
}
|
||||
return map;
|
||||
}, [flags]);
|
||||
|
||||
const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
|
||||
const flagged = Object.entries(flags);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return resolveActions({
|
||||
detail: data,
|
||||
currentUserId,
|
||||
can,
|
||||
flaggedCount: flagged.length,
|
||||
hasPendingInspection: Boolean(pendingInspection),
|
||||
reasons: {
|
||||
wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
|
||||
notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
|
||||
noPermission: t('review.disabled.noPermission', 'You do not have permission'),
|
||||
needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
|
||||
needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
|
||||
needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
|
||||
},
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Skeleton height={36} width={320} mb="lg" />
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<Skeleton height={280} radius="md" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Skeleton height={420} radius="md" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<Skeleton height={280} radius="md" />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<ErrorState
|
||||
title={t('review.errorTitle', 'Could not load this application')}
|
||||
description={extractErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const app = data.application;
|
||||
const status = app.status;
|
||||
const presentation = presentationFor(app.licenseType?.key);
|
||||
const sla = computeSla(app);
|
||||
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
|
||||
|
||||
const rawThreshold = app.licenseType?.capitalThreshold;
|
||||
const threshold =
|
||||
rawThreshold === null || rawThreshold === undefined ? undefined : Number(rawThreshold);
|
||||
/** Stages where the officer can still record the verified capital. */
|
||||
const needsCapital =
|
||||
Boolean(threshold) &&
|
||||
['UNDER_REVIEW', 'UNDER_EVALUATION', 'INSPECTION_PENDING', 'INSPECTION_COMPLETED'].includes(
|
||||
status,
|
||||
);
|
||||
|
||||
async function run(action: () => Promise<unknown>, success: string) {
|
||||
try {
|
||||
await action();
|
||||
notifications.show({ color: 'teal', title: success, message: '' });
|
||||
refetch();
|
||||
refetchInspections();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('review.actionFailed', 'Action failed'),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions with their own dedicated form open that; the rest confirm. */
|
||||
function handleAction(action: ResolvedAction) {
|
||||
switch (action.id) {
|
||||
case 'schedule-inspection':
|
||||
setInspectionOpen(true);
|
||||
return;
|
||||
case 'record-inspection':
|
||||
setResultOpen(true);
|
||||
return;
|
||||
case 'copy-link':
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('review.linkCopied', 'Link copied'),
|
||||
message: '',
|
||||
});
|
||||
return;
|
||||
case 'print':
|
||||
window.print();
|
||||
return;
|
||||
case 'audit-trail':
|
||||
setRailOpen(true);
|
||||
return;
|
||||
case 'download-documents':
|
||||
for (const attachment of data?.attachments ?? []) {
|
||||
const url = attachment.files?.[0]?.url;
|
||||
if (url) window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
return;
|
||||
default:
|
||||
setPendingAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDecision(submission: DecisionSubmission) {
|
||||
const action = pendingAction;
|
||||
if (!action) return;
|
||||
setBusyAction(action.id);
|
||||
try {
|
||||
switch (action.id) {
|
||||
case 'claim':
|
||||
// Claim is fired from the queue in practice; kept here for the case
|
||||
// where an officer opens an unclaimed application directly.
|
||||
break;
|
||||
case 'complete-review':
|
||||
await run(
|
||||
() => completeReview({ id, capitalAmountVerified: capital }).unwrap(),
|
||||
t('review.done.completeReview', 'Review completed'),
|
||||
);
|
||||
break;
|
||||
case 'approve-documents':
|
||||
await run(
|
||||
() => approveDocuments({ id }).unwrap(),
|
||||
t('review.done.approveDocuments', 'Documents approved'),
|
||||
);
|
||||
break;
|
||||
case 'final-approve':
|
||||
await run(
|
||||
() => finalApprove({ id, capitalAmountVerified: capital }).unwrap(),
|
||||
t('review.done.finalApprove', 'Approved'),
|
||||
);
|
||||
break;
|
||||
case 'request-adjustment':
|
||||
await run(async () => {
|
||||
await requestAdjustment({
|
||||
id,
|
||||
generalRemark: submission.reason,
|
||||
notificationBody: submission.notificationBody,
|
||||
items: flagged
|
||||
// Only the ticked deficiencies are sent, so the applicant can
|
||||
// edit exactly the list they were shown.
|
||||
.filter(([key]) =>
|
||||
submission.deficiencies.length
|
||||
? submission.deficiencies.includes(key)
|
||||
: true,
|
||||
)
|
||||
.map(([key, flag]) => ({
|
||||
targetType: flag.targetType,
|
||||
targetKey: key,
|
||||
remark: flag.remark,
|
||||
})),
|
||||
}).unwrap();
|
||||
setFlags({});
|
||||
}, t('review.done.requestAdjustment', 'Adjustment requested'));
|
||||
break;
|
||||
case 'reject':
|
||||
await run(
|
||||
() =>
|
||||
rejectApplication({
|
||||
id,
|
||||
reason: submission.reason,
|
||||
// The preview the officer edited is what actually gets sent.
|
||||
notificationBody: submission.notificationBody,
|
||||
}).unwrap(),
|
||||
t('review.done.reject', 'Application rejected'),
|
||||
);
|
||||
break;
|
||||
case 'confirm-payment':
|
||||
await run(
|
||||
() => confirmPayment(id).unwrap(),
|
||||
t('review.done.confirmPayment', 'Payment confirmed'),
|
||||
);
|
||||
break;
|
||||
case 'hold':
|
||||
await run(
|
||||
() => holdApplication({ id, reason: submission.reason }).unwrap(),
|
||||
t('review.done.hold', 'Application placed on hold'),
|
||||
);
|
||||
break;
|
||||
case 'resume':
|
||||
await run(
|
||||
() => resumeApplication({ id, remark: submission.reason }).unwrap(),
|
||||
t('review.done.resume', 'Application resumed'),
|
||||
);
|
||||
break;
|
||||
case 'escalate':
|
||||
if (!submission.officerId) return;
|
||||
await run(
|
||||
() =>
|
||||
escalateApplication({
|
||||
id,
|
||||
supervisorId: submission.officerId as string,
|
||||
reason: submission.reason,
|
||||
}).unwrap(),
|
||||
t('review.done.escalate', 'Escalated'),
|
||||
);
|
||||
break;
|
||||
case 'assign':
|
||||
if (!submission.officerId) return;
|
||||
await run(
|
||||
() =>
|
||||
assignApplication({
|
||||
id,
|
||||
officerId: submission.officerId as string,
|
||||
remark: submission.reason,
|
||||
}).unwrap(),
|
||||
t('review.done.assign', 'Reassigned'),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||
{STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
{app.adjustmentRound > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.round', { count: app.adjustmentRound, defaultValue: 'round {{count}}' })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={
|
||||
railOpen
|
||||
? t('review.hideActivity', 'Hide activity')
|
||||
: t('review.showActivity', 'Show activity')
|
||||
}
|
||||
>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => setRailOpen((o) => !o)}>
|
||||
{railOpen ? (
|
||||
<IconLayoutSidebarRightCollapse size={18} />
|
||||
) : (
|
||||
<IconLayoutSidebarRightExpand size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
{/* Zone 1 — sticky summary rail. */}
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<Stack style={{ position: 'sticky', top: 16 }}>
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
|
||||
<SummaryRow
|
||||
label={t('review.submitted', 'Submitted')}
|
||||
value={
|
||||
app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Eligibility, checked and shown — not applied invisibly. */}
|
||||
{eligibility.length > 0 && (
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t('review.eligibility', 'Eligibility')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{eligibility.map((rule) => (
|
||||
<Group key={rule.id} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={18}
|
||||
radius="xl"
|
||||
variant="light"
|
||||
color={
|
||||
rule.status === 'pass'
|
||||
? 'teal'
|
||||
: rule.status === 'fail'
|
||||
? 'red'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
{rule.status === 'pass' ? (
|
||||
<IconCheck size={11} />
|
||||
) : rule.status === 'fail' ? (
|
||||
<IconX size={11} />
|
||||
) : (
|
||||
<IconQuestionMark size={11} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{rule.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{rule.actual}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t('review.statusTimeline', 'Progress')}
|
||||
</Text>
|
||||
<Timeline bulletSize={12} lineWidth={2} active={data.history.length}>
|
||||
{data.history.slice(-5).map((entry) => (
|
||||
<Timeline.Item
|
||||
key={entry.id}
|
||||
title={
|
||||
<Text size="xs" fw={600}>
|
||||
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Zone 2 — the application itself. */}
|
||||
<Grid.Col span={{ base: 12, md: railOpen ? 6 : 9 }}>
|
||||
<Tabs defaultValue={sections[0]}>
|
||||
<Tabs.List mb="md">
|
||||
{/* Tabs with nothing behind them are not rendered at all. */}
|
||||
{sections.includes('overview') && formSections.length > 0 && (
|
||||
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('financials') && (
|
||||
<Tabs.Tab value="financials">{t('review.tabs.financials', 'Financials')}</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('documents') && (
|
||||
<Tabs.Tab value="documents">
|
||||
{t('review.tabs.documents', 'Documents')} ({data.attachments.length})
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('staff') && data.staff.length > 0 && (
|
||||
<Tabs.Tab value="staff">
|
||||
{t('review.tabs.staff', 'Staff')} ({data.staff.length})
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('inspection') && app.licenseType?.inspectionRequired && (
|
||||
<Tabs.Tab value="inspection">{t('review.tabs.inspection', 'Inspection')}</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={Boolean(flags[sectionKey])}
|
||||
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
|
||||
/>
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
<TextInput
|
||||
mt="xs"
|
||||
size="xs"
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
value={flags[sectionKey].remark}
|
||||
onChange={(e) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[sectionKey]: { ...p[sectionKey], remark: e.currentTarget.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="financials">
|
||||
<Paper withBorder p="md">
|
||||
{needsCapital ? (
|
||||
<>
|
||||
<NumberInput
|
||||
label={t('review.verifiedCapital', 'Verified capital (ETB)')}
|
||||
description={
|
||||
threshold
|
||||
? t('review.capitalHint', {
|
||||
min: threshold.toLocaleString(i18n.language),
|
||||
defaultValue:
|
||||
'Minimum {{min}} — check against the bank letter',
|
||||
})
|
||||
: t('review.capitalHintNoMin', 'Checked against the bank letter')
|
||||
}
|
||||
value={capital ?? ''}
|
||||
onChange={(v) => setCapital(Number(v) || undefined)}
|
||||
thousandSeparator=","
|
||||
error={
|
||||
capital !== undefined && threshold && capital < threshold
|
||||
? t('review.belowMinimum', {
|
||||
min: threshold.toLocaleString(i18n.language),
|
||||
defaultValue: 'Below the {{min}} minimum',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t('review.declared', 'Applicant declared')}{' '}
|
||||
{app.capitalAmountDeclared
|
||||
? Number(app.capitalAmountDeclared).toLocaleString(i18n.language)
|
||||
: '—'}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.capitalLocked', 'Capital can no longer be edited at this stage.')}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
<DocumentsTab
|
||||
applicationId={id}
|
||||
attachments={data.attachments}
|
||||
requirements={requirements?.documentRequirements ?? []}
|
||||
flags={documentFlags}
|
||||
onToggleFlag={(key) => toggleFlag('DOCUMENT', key)}
|
||||
onFlagRemark={(key, remark) =>
|
||||
setFlags((p) => ({ ...p, [key]: { ...p[key], remark } }))
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="staff">
|
||||
<Card withBorder padding="md">
|
||||
<Table withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('review.role', 'Role')}</Table.Th>
|
||||
<Table.Th>{t('review.name', 'Name')}</Table.Th>
|
||||
<Table.Th>{t('review.evidence', 'Evidence')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.staff.map((member) => (
|
||||
<Table.Tr key={member.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs">{member.roleKey}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{member.fullName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{(member.documents ?? []).map((doc) => (
|
||||
<Badge key={doc.id} size="xs" variant="light">
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inspection">
|
||||
<Paper withBorder p="md">
|
||||
{inspections.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.noInspections', 'No inspection has been scheduled yet.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{inspections.map((inspection) => (
|
||||
<Group key={inspection.id} justify="space-between">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{inspection.scheduledDate
|
||||
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
|
||||
: t('review.unscheduled', 'Not scheduled')}
|
||||
</Text>
|
||||
{inspection.findings && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{inspection.findings}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
|
||||
>
|
||||
{inspection.result ?? inspection.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{status === 'PAYMENT_PENDING' && (
|
||||
<Alert mt="md" color="yellow" icon={<IconAlertTriangle size={16} />}>
|
||||
{t('review.awaitingPayment', {
|
||||
amount: app.feeAmount,
|
||||
currency: app.feeCurrency,
|
||||
defaultValue: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* Zone 3 — activity and audit trail. */}
|
||||
{railOpen && (
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<ActivityRail detail={data} />
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<DecisionBar
|
||||
status={status}
|
||||
assigneeName={app.assignedOfficerId ? t('review.assigned', 'Assigned') : null}
|
||||
sla={sla}
|
||||
actions={actions}
|
||||
busyAction={busyAction}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
|
||||
<DecisionConfirmModal
|
||||
action={pendingAction}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
applicationNumber={app.applicationNumber}
|
||||
flaggedDocuments={flagged.map(([key]) => key)}
|
||||
officers={officers}
|
||||
submitting={Boolean(busyAction)}
|
||||
onClose={() => setPendingAction(null)}
|
||||
onConfirm={submitDecision}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={inspectionOpen}
|
||||
onClose={() => setInspectionOpen(false)}
|
||||
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
label={t('review.dateTime', 'Date and time')}
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={t('review.pickDate', 'Pick a date and time first')}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
hidden
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
size="lg"
|
||||
disabled={!inspectionDate}
|
||||
aria-label={t('review.schedule', 'Schedule')}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: new Date(inspectionDate).toISOString(),
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
}, t('review.done.scheduled', 'Inspection scheduled'))
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={resultOpen}
|
||||
onClose={() => setResultOpen(false)}
|
||||
title={t('review.inspectionResult', 'Inspection result')}
|
||||
>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label={t('review.findings', 'Findings')}
|
||||
withAsterisk
|
||||
value={findings}
|
||||
onChange={(e) => setFindings(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
<Group grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="lg"
|
||||
disabled={!findings.trim() || !pendingInspection}
|
||||
aria-label={t('review.passed', 'Passed')}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
// Guarded by the disabled state above; narrowing it here
|
||||
// keeps that guarantee in the type system too.
|
||||
if (!pendingInspection) return;
|
||||
await recordResult({
|
||||
inspectionId: pendingInspection.id,
|
||||
applicationId: id,
|
||||
result: 'PASSED',
|
||||
findings,
|
||||
}).unwrap();
|
||||
setResultOpen(false);
|
||||
}, t('review.done.inspectionPassed', 'Inspection passed'))
|
||||
}
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="lg"
|
||||
disabled={!findings.trim() || !pendingInspection}
|
||||
aria-label={t('review.failed', 'Failed')}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
// Guarded by the disabled state above; narrowing it here
|
||||
// keeps that guarantee in the type system too.
|
||||
if (!pendingInspection) return;
|
||||
await recordResult({
|
||||
inspectionId: pendingInspection.id,
|
||||
applicationId: id,
|
||||
result: 'FAILED',
|
||||
findings,
|
||||
}).unwrap();
|
||||
setResultOpen(false);
|
||||
}, t('review.done.inspectionFailed', 'Inspection failed'))
|
||||
}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={500} ta="right" truncate>
|
||||
{value || '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseReviewPage;
|
||||
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal file
152
apps/backoffice/src/app/features/license-review/queue-views.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { LicenseStatus, QueueCounts, QueueFilter } from '@ema-platform/api';
|
||||
|
||||
export type SavedViewId =
|
||||
| 'unassigned'
|
||||
| 'mine'
|
||||
| 'awaitingApplicant'
|
||||
| 'overdue'
|
||||
| 'readyToIssue'
|
||||
| 'all';
|
||||
|
||||
export interface SavedView {
|
||||
id: SavedViewId;
|
||||
labelKey: string;
|
||||
/** Which count from `/counts` labels the tab. */
|
||||
countKey: keyof QueueCounts;
|
||||
/** Filters this view pins. The user's own facets layer on top. */
|
||||
filter: Partial<QueueFilter>;
|
||||
/** Which list endpoint backs it. */
|
||||
source: 'queue' | 'mine' | 'all';
|
||||
}
|
||||
|
||||
const AWAITING_APPLICANT: LicenseStatus[] = ['RESUBMIT_REQUIRED'];
|
||||
const READY_TO_ISSUE: LicenseStatus[] = ['PAYMENT_CONFIRMED'];
|
||||
|
||||
/**
|
||||
* The officer's saved views.
|
||||
*
|
||||
* Replaces a two-option SegmentedControl (Unclaimed / Mine) that could not
|
||||
* express the questions officers actually ask — what is late, what is waiting
|
||||
* on the applicant, what is ready to issue. Each is a filter preset over the
|
||||
* same grid rather than a separate screen.
|
||||
*/
|
||||
export const SAVED_VIEWS: SavedView[] = [
|
||||
{
|
||||
id: 'unassigned',
|
||||
labelKey: 'queue.views.unassigned',
|
||||
countKey: 'unassigned',
|
||||
filter: {},
|
||||
source: 'queue',
|
||||
},
|
||||
{
|
||||
id: 'mine',
|
||||
labelKey: 'queue.views.mine',
|
||||
countKey: 'mine',
|
||||
filter: {},
|
||||
source: 'mine',
|
||||
},
|
||||
{
|
||||
id: 'awaitingApplicant',
|
||||
labelKey: 'queue.views.awaitingApplicant',
|
||||
countKey: 'awaitingApplicant',
|
||||
filter: { status: AWAITING_APPLICANT },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'overdue',
|
||||
labelKey: 'queue.views.overdue',
|
||||
countKey: 'overdue',
|
||||
filter: { overdue: true },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'readyToIssue',
|
||||
labelKey: 'queue.views.readyToIssue',
|
||||
countKey: 'readyToIssue',
|
||||
filter: { status: READY_TO_ISSUE },
|
||||
source: 'all',
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
labelKey: 'queue.views.all',
|
||||
countKey: 'all',
|
||||
filter: {},
|
||||
source: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
|
||||
|
||||
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
|
||||
|
||||
export function readLastView(): SavedViewId {
|
||||
try {
|
||||
const stored = localStorage.getItem(LAST_VIEW_KEY) as SavedViewId | null;
|
||||
return SAVED_VIEWS.some((v) => v.id === stored) ? (stored as SavedViewId) : DEFAULT_VIEW;
|
||||
} catch {
|
||||
return DEFAULT_VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastView(id: SavedViewId): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_VIEW_KEY, id);
|
||||
} catch {
|
||||
// Not persisting the last view is cosmetic; never break the page for it.
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ URL round-trip
|
||||
|
||||
/**
|
||||
* Reads the user's facets out of the query string.
|
||||
*
|
||||
* Filters live in the URL so a filtered queue is a shareable link — "here are
|
||||
* the six overdue MTO applications" should be something an officer can paste
|
||||
* into a message, not a state they describe in prose.
|
||||
*/
|
||||
export function filterFromSearchParams(params: URLSearchParams): QueueFilter {
|
||||
const filter: QueueFilter = {};
|
||||
const search = params.get('q');
|
||||
if (search) filter.search = search;
|
||||
const type = params.get('type');
|
||||
if (type) filter.licenseTypeId = type;
|
||||
const status = params.get('status');
|
||||
if (status) filter.status = status.split(',') as LicenseStatus[];
|
||||
const assignee = params.get('assignee');
|
||||
if (assignee) filter.assignee = assignee;
|
||||
const from = params.get('from');
|
||||
if (from) filter.submittedFrom = from;
|
||||
const to = params.get('to');
|
||||
if (to) filter.submittedTo = to;
|
||||
const sortBy = params.get('sort');
|
||||
if (sortBy) filter.sortBy = sortBy as QueueFilter['sortBy'];
|
||||
const sortDir = params.get('dir');
|
||||
if (sortDir === 'ASC' || sortDir === 'DESC') filter.sortDir = sortDir;
|
||||
const page = params.get('page');
|
||||
if (page) {
|
||||
const parsed = Number(page);
|
||||
if (Number.isFinite(parsed) && parsed > 0) filter.skip = undefined;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Inverse of {@link filterFromSearchParams}. Omits defaults to keep URLs short. */
|
||||
export function searchParamsFromFilter(
|
||||
filter: QueueFilter,
|
||||
view: SavedViewId,
|
||||
page: number,
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (view !== DEFAULT_VIEW) params.set('view', view);
|
||||
if (filter.search) params.set('q', filter.search);
|
||||
if (filter.licenseTypeId) params.set('type', filter.licenseTypeId);
|
||||
if (filter.status?.length) params.set('status', filter.status.join(','));
|
||||
if (filter.assignee) params.set('assignee', filter.assignee);
|
||||
if (filter.submittedFrom) params.set('from', filter.submittedFrom);
|
||||
if (filter.submittedTo) params.set('to', filter.submittedTo);
|
||||
if (filter.sortBy) params.set('sort', filter.sortBy);
|
||||
if (filter.sortDir && filter.sortDir !== 'ASC') params.set('dir', filter.sortDir);
|
||||
if (page > 1) params.set('page', String(page));
|
||||
return params;
|
||||
}
|
||||
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal file
89
apps/backoffice/src/app/features/license-review/sla.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
|
||||
/** Amber once this much of the window has been consumed. */
|
||||
const WARNING_RATIO = 0.7;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface SlaState {
|
||||
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
|
||||
/** Mantine colour. Always paired with `label` — never colour alone. */
|
||||
color: string;
|
||||
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
|
||||
label: string;
|
||||
/** The full explanation, including the target, for the tooltip. */
|
||||
tooltip: string;
|
||||
/** Fraction of the window used, clamped to 0..1. */
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
||||
if (hours < 1) return '<1h';
|
||||
if (hours < 48) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How an application is tracking against its licence type's SLA.
|
||||
*
|
||||
* Types with no `slaHours` are untracked rather than instantly overdue — the
|
||||
* authority has not set a target for them, which is not the same as missing
|
||||
* one. Decided applications stop the clock: an approval that took three weeks
|
||||
* is history, not an outstanding breach.
|
||||
*/
|
||||
export function computeSla(
|
||||
application: LicenseApplication,
|
||||
now: number = Date.now(),
|
||||
): SlaState {
|
||||
const slaHours = application.licenseType?.slaHours;
|
||||
const submittedAt = application.submittedAt;
|
||||
|
||||
if (!slaHours || !submittedAt) {
|
||||
return {
|
||||
state: 'untracked',
|
||||
color: 'gray',
|
||||
label: '—',
|
||||
tooltip: 'No turnaround target is set for this licence type.',
|
||||
ratio: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const submitted = new Date(submittedAt).getTime();
|
||||
const target = submitted + slaHours * HOUR_MS;
|
||||
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
||||
const window = slaHours * HOUR_MS;
|
||||
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
|
||||
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
|
||||
|
||||
if (application.decidedAt) {
|
||||
const met = elapsed <= window;
|
||||
return {
|
||||
state: 'decided',
|
||||
color: met ? 'teal' : 'gray',
|
||||
label: met ? 'Met' : 'Missed',
|
||||
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
const remaining = target - now;
|
||||
if (remaining < 0) {
|
||||
return {
|
||||
state: 'breached',
|
||||
color: 'red',
|
||||
label: `Overdue ${formatDuration(remaining)}`,
|
||||
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
|
||||
ratio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const used = elapsed / window;
|
||||
return {
|
||||
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
||||
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
||||
label: `${formatDuration(remaining)} left`,
|
||||
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* True when the user is typing, so a shortcut must not steal the keystroke.
|
||||
*
|
||||
* Without this, typing a company name into the search box would jump rows on
|
||||
* every "j" and try to claim on every "c".
|
||||
*/
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
interface QueueKeyboardHandlers {
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onOpen: () => void;
|
||||
onClaim: () => void;
|
||||
onEscape: () => void;
|
||||
onHelp: () => void;
|
||||
/** Disabled while a modal or drawer owns the keyboard. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue keyboard navigation: j/k to move, Enter to open, c to claim.
|
||||
*
|
||||
* Officers work through a queue one row at a time all day; reaching for the
|
||||
* mouse for each is the slow path. Modifier combinations are ignored so
|
||||
* browser and OS shortcuts keep working.
|
||||
*/
|
||||
export function useQueueKeyboard({
|
||||
onNext,
|
||||
onPrevious,
|
||||
onOpen,
|
||||
onClaim,
|
||||
onEscape,
|
||||
onHelp,
|
||||
enabled = true,
|
||||
}: QueueKeyboardHandlers): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (isTyping(event.target)) {
|
||||
// Esc still works while typing — it is how you get out of the field.
|
||||
if (event.key === 'Escape') onEscape();
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
event.preventDefault();
|
||||
onNext();
|
||||
break;
|
||||
case 'k':
|
||||
event.preventDefault();
|
||||
onPrevious();
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
break;
|
||||
case 'c':
|
||||
event.preventDefault();
|
||||
onClaim();
|
||||
break;
|
||||
case 'Escape':
|
||||
onEscape();
|
||||
break;
|
||||
case '?':
|
||||
event.preventDefault();
|
||||
onHelp();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [enabled, onNext, onPrevious, onOpen, onClaim, onEscape, onHelp]);
|
||||
}
|
||||
|
||||
/** Rows shown in the `?` cheatsheet. */
|
||||
export const KEYBOARD_SHORTCUTS: Array<{ keys: string; labelKey: string }> = [
|
||||
{ keys: '⌘K', labelKey: 'shortcuts.commandPalette' },
|
||||
{ keys: 'j / k', labelKey: 'shortcuts.moveRow' },
|
||||
{ keys: 'Enter', labelKey: 'shortcuts.openRow' },
|
||||
{ keys: 'c', labelKey: 'shortcuts.claimRow' },
|
||||
{ keys: 'Esc', labelKey: 'shortcuts.dismiss' },
|
||||
{ keys: '?', labelKey: 'shortcuts.help' },
|
||||
];
|
||||
@@ -123,7 +123,7 @@ function TreeNode({
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
|
||||
{!hasChildren && <Box w={rem(18)} style={{ flexShrink: 0 }} />}
|
||||
<IconMapPin
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
|
||||
@@ -1,183 +1,195 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconShieldOff,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { MOCK_FF_APPLICATIONS } from '../../freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
|
||||
import { MOCK_SA_APPLICATIONS } from '../../shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
|
||||
import { MOCK_COMBINED_APPLICATIONS } from '../../combined-license/pages/CombinedLicenseQueuePage';
|
||||
import { MOCK_JV_APPLICATIONS } from '../../joint-investment-license/pages/JointInvestmentLicenseQueuePage';
|
||||
import { MOCK_MTO_APPLICATIONS } from '../../mto-license/pages/MtoLicenseQueuePage';
|
||||
import { MOCK_WAIVER_APPLICATIONS } from '../../waiver/pages/WaiverQueuePage';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Submitted: 'gray',
|
||||
'Under Review': 'blue',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Resubmit Required': 'orange',
|
||||
'Certificate Issued': 'green',
|
||||
Completed: 'green',
|
||||
};
|
||||
|
||||
const LICENSE_LINES = [
|
||||
{ key: 'freight-forwarder', label: 'Freight Forwarder License', route: '/freight-forwarder-license', icon: IconTruck, apps: MOCK_FF_APPLICATIONS },
|
||||
{ key: 'shipping-agent', label: 'Shipping Agent License', route: '/shipping-agent-license', icon: IconShip, apps: MOCK_SA_APPLICATIONS },
|
||||
{ key: 'combined', label: 'Combined License', route: '/combined-license', icon: IconStack2, apps: MOCK_COMBINED_APPLICATIONS },
|
||||
{ key: 'joint-investment', label: 'Joint Investment License', route: '/joint-investment-license', icon: IconUsers, apps: MOCK_JV_APPLICATIONS },
|
||||
{ key: 'mto', label: 'MTO License', route: '/mto-license', icon: IconTruck, apps: MOCK_MTO_APPLICATIONS },
|
||||
{ key: 'waiver', label: 'Waiver', route: '/waiver', icon: IconShieldOff, apps: MOCK_WAIVER_APPLICATIONS },
|
||||
] as const;
|
||||
|
||||
const IN_PROGRESS_STATUSES = new Set(['Submitted', 'Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Resubmit Required']);
|
||||
const ISSUED_STATUSES = new Set(['Certificate Issued', 'Completed']);
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconTruck;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<ThemeIcon size={46} radius="md" variant="light" color={color}>
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz={30} fw={800} mt="md" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{label}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
useGetAssignedToMeQuery,
|
||||
useGetQueueQuery,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Logistics department overview.
|
||||
*
|
||||
* Every figure is derived from the licence applications actually in the
|
||||
* system. This previously summed six hardcoded arrays, so the department head
|
||||
* saw counts for applications that had never been filed.
|
||||
*/
|
||||
export function LogisticsHeadDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
|
||||
const allApps = LICENSE_LINES.flatMap((line) =>
|
||||
line.apps.map((a) => ({ ...a, _line: line.label, _route: line.route }))
|
||||
);
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: allApps.length,
|
||||
inProgress: allApps.filter((a) => IN_PROGRESS_STATUSES.has(a.status)).length,
|
||||
issued: allApps.filter((a) => ISSUED_STATUSES.has(a.status)).length,
|
||||
rejected: allApps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
|
||||
const recent = [...allApps]
|
||||
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
|
||||
.slice(0, 6);
|
||||
const byStatus = all.reduce<Record<string, number>>((acc, app) => {
|
||||
acc[app.status] = (acc[app.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const stats = [
|
||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
||||
{ label: 'In progress', value: inProgress.length, color: 'indigo' },
|
||||
{
|
||||
label: 'Awaiting payment',
|
||||
value: byStatus['PAYMENT_PENDING'] ?? 0,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
label: 'Needs applicant action',
|
||||
value: byStatus['RESUBMIT_REQUIRED'] ?? 0,
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
const recent = [...all]
|
||||
.sort((a, b) =>
|
||||
(b.submittedAt ?? b.createdAt).localeCompare(a.submittedAt ?? a.createdAt),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="indigo" variant="light">
|
||||
<IconStack2 size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Title order={2}>Logistics Licensing Department</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Overview across freight forwarder, shipping agent, combined, joint investment, MTO and waiver applications
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Logistics overview
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="lg">
|
||||
Licence applications currently in the department.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<StatCard label="Total Applications" value={stats.total} icon={IconStack2} color="indigo" />
|
||||
<StatCard label="In Progress" value={stats.inProgress} icon={IconClockHour4} color="yellow" />
|
||||
<StatCard label="Certificates Issued" value={stats.issued} icon={IconCircleCheck} color="teal" />
|
||||
<StatCard label="Rejected" value={stats.rejected} icon={IconShieldOff} color="red" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
|
||||
{stats.map((stat) => (
|
||||
<Card withBorder key={stat.label} padding="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg" align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Text fw={700} fz="lg" mb="md">Recent Applications</Text>
|
||||
<Stack gap={0}>
|
||||
{recent.map((a, i) => (
|
||||
<Group
|
||||
key={a.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => navigate(`${a._route}/${a.id}`)}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{a.companyName}</Text>
|
||||
<Text size="xs" c="dimmed">{a._line} — {a.id}</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={STATUS_COLOR[a.status] ?? 'gray'} radius="sm">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Card withBorder padding={0} radius="md">
|
||||
<Group justify="space-between" p="md" pb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Most recent
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/licence-review')}
|
||||
>
|
||||
View all <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
|
||||
</Text>
|
||||
</Group>
|
||||
{recent.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
No licence applications yet.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{recent.map((app) => (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/licence-review/${app.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLORS[app.status as LicenseStatus]}
|
||||
>
|
||||
{STATUS_LABELS[app.status as LicenseStatus]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="lg">License Queues</Text>
|
||||
<Anchor size="sm" fw={600}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
All
|
||||
<IconArrowRight size={14} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{LICENSE_LINES.map((line) => (
|
||||
<Button
|
||||
key={line.key}
|
||||
fullWidth
|
||||
variant="light"
|
||||
leftSection={<line.icon size={16} />}
|
||||
justify="space-between"
|
||||
rightSection={<Badge size="sm" variant="filled" color="indigo">{line.apps.length}</Badge>}
|
||||
onClick={() => navigate(line.route)}
|
||||
>
|
||||
{line.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
By status
|
||||
</Text>
|
||||
{Object.keys(byStatus).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing in the pipeline.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{Object.entries(byStatus)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([status, count]) => (
|
||||
<Group key={status} justify="space-between">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={STATUS_COLORS[status as LicenseStatus]}
|
||||
>
|
||||
{STATUS_LABELS[status as LicenseStatus] ?? status}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600}>
|
||||
{count}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogisticsHeadDashboardPage;
|
||||
|
||||
@@ -1,381 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconHeart,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface MedicalRecord {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
certNumber: string;
|
||||
issuedBy: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
daysLeft: number;
|
||||
status: 'Pending Verification' | 'Verified' | 'Rejected' | 'Expiring' | 'Expired';
|
||||
restrictions: string;
|
||||
fileName: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
const MOCK_RECORDS: MedicalRecord[] = [
|
||||
{ id: 'MV-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', certNumber: 'MC-2024-001', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-03-15', expiryDate: '2026-03-14', daysLeft: 45, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_abebe.pdf', submittedAt: '2024-03-16' },
|
||||
{ id: 'MV-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', certNumber: 'MC-2024-002', issuedBy: 'Approved Medical Centre — Dire Dawa', issuedDate: '2024-05-01', expiryDate: '2026-04-30', daysLeft: 120, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_sara.pdf', submittedAt: '2024-05-02' },
|
||||
{ id: 'MV-003', seafarerId: 'SF-2023-0088', name: 'Tekle Haile', email: 'tekle.h@email.com', certNumber: 'MC-2023-088', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-02-20', expiryDate: '2026-02-28', daysLeft: 31, status: 'Expiring', restrictions: 'Colour blind - deck duties restricted', fileName: 'medical_cert_tekle.pdf', submittedAt: '2024-02-21' },
|
||||
{ id: 'MV-004', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', certNumber: 'MC-2024-003', issuedBy: 'Approved Medical Centre — Addis Ababa', issuedDate: '2024-04-10', expiryDate: '2026-04-09', daysLeft: 95, status: 'Pending Verification', restrictions: 'None', fileName: 'medical_cert_dawit.pdf', submittedAt: '2024-04-11' },
|
||||
{ id: 'MV-005', seafarerId: 'SF-2022-0210', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', certNumber: 'MC-2022-210', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-01-05', expiryDate: '2026-01-30', daysLeft: 11, status: 'Expiring', restrictions: 'None', fileName: 'medical_cert_yonas.pdf', submittedAt: '2024-01-06' },
|
||||
{ id: 'MV-006', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', certNumber: 'MC-2024-004', issuedBy: 'EMA Medical Centre — Addis Ababa', issuedDate: '2024-06-01', expiryDate: '2026-05-31', daysLeft: 365, status: 'Verified', restrictions: 'None', fileName: 'medical_cert_hana.pdf', submittedAt: '2024-06-02' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'Pending Verification': 'yellow',
|
||||
Verified: 'teal',
|
||||
Rejected: 'red',
|
||||
Expiring: 'orange',
|
||||
Expired: 'red',
|
||||
};
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerificationDrawer({
|
||||
record,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
}: {
|
||||
record: MedicalRecord | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'verify' | 'reject', remarks: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'verify' | 'reject' | null>(null);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const submit = (action: 'verify' | 'reject') => {
|
||||
onAction(record.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Medical Certificate — ${record.name}`}
|
||||
position="right"
|
||||
size="lg"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Certificate details */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Certificate Details</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
['Seafarer ID', record.seafarerId],
|
||||
['Certificate No.', record.certNumber],
|
||||
['Issued By', record.issuedBy],
|
||||
['Issue Date', formatDate(record.issuedDate)],
|
||||
['Expiry Date', formatDate(record.expiryDate)],
|
||||
['Days Remaining', `${record.daysLeft} days`],
|
||||
['Restrictions', record.restrictions],
|
||||
['Submitted', formatDate(record.submittedAt)],
|
||||
].map(([label, value]) => (
|
||||
<Box key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={label === 'Days Remaining' ? 700 : 400} c={label === 'Days Remaining' && record.daysLeft <= 30 ? 'red' : label === 'Days Remaining' && record.daysLeft <= 90 ? 'orange' : undefined}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Document preview */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">Certificate Document</Text>
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={13} />}>{record.fileName}</Button>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: rem(120),
|
||||
borderRadius: rem(8),
|
||||
border: '2px dashed var(--mantine-color-default-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap={4}>
|
||||
<IconEye size={24} color="var(--mantine-color-gray-4)" />
|
||||
<Text fz="xs" c="dimmed">Click download to view the certificate</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Officer remarks */}
|
||||
<Textarea
|
||||
label="Verification Remarks"
|
||||
placeholder="Add notes about validity, restrictions, or rejection reason…"
|
||||
minRows={3}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
{record.status === 'Pending Verification' && (
|
||||
<Group grow>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => setConfirmModal('verify')}
|
||||
>
|
||||
Verify & Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => setConfirmModal('reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{record.status !== 'Pending Verification' && (
|
||||
<Text fz="sm" c="dimmed" ta="center">This certificate has already been {record.status.toLowerCase()}.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={`Confirm ${confirmModal === 'verify' ? 'Verification' : 'Rejection'}`}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'verify'
|
||||
? 'Are you sure you want to verify and approve this medical certificate? The seafarer will be notified.'
|
||||
: 'Are you sure you want to reject this certificate? Please ensure you have added a reason in the remarks.'}
|
||||
</Text>
|
||||
{!remarks && confirmModal === 'reject' && (
|
||||
<Text fz="xs" c="red" mb="sm">Please add rejection remarks before proceeding.</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal === 'verify' ? 'teal' : 'red'}
|
||||
disabled={!remarks && confirmModal === 'reject'}
|
||||
onClick={() => submit(confirmModal!)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const [records, setRecords] = useState<MedicalRecord[]>(MOCK_RECORDS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<MedicalRecord | null>(null);
|
||||
|
||||
const stats = {
|
||||
pending: records.filter((r) => r.status === 'Pending Verification').length,
|
||||
expiring: records.filter((r) => r.status === 'Expiring').length,
|
||||
verified: records.filter((r) => r.status === 'Verified').length,
|
||||
critical: records.filter((r) => r.daysLeft <= 30).length,
|
||||
};
|
||||
|
||||
const filtered = records.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || r.name.toLowerCase().includes(q) || r.seafarerId.toLowerCase().includes(q) || r.certNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || r.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const handleAction = (id: string, action: 'verify' | 'reject', remarks: string) => {
|
||||
setRecords((prev) => prev.map((r) =>
|
||||
r.id === id ? { ...r, status: action === 'verify' ? 'Verified' : 'Rejected' } : r
|
||||
));
|
||||
notify.success(`Medical certificate ${action === 'verify' ? 'verified' : 'rejected'} successfully.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Medical Certificate Verification</Title>
|
||||
<Text fz="sm" c="dimmed">Review and verify seafarer medical certificates (STCW — valid 2 years)</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Pending Verification', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Expiring (90 days)', value: stats.expiring, color: 'orange', icon: IconAlertTriangle },
|
||||
{ label: 'Critical (≤30 days)', value: stats.critical, color: 'red', icon: IconAlertCircle },
|
||||
{ label: 'Verified', value: stats.verified, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Medical Certificate Records</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, ID or cert number…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(260) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Pending Verification', 'Verified', 'Rejected', 'Expiring', 'Expired']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(190) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconHeart size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No records found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer', 'Cert Number', 'Issued By', 'Issue Date', 'Expiry Date', 'Days Left', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((rec) => (
|
||||
<Table.Tr key={rec.id}>
|
||||
<Table.Td>
|
||||
<Text fz="xs" fw={500}>{rec.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{rec.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={600}>{rec.certNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" maw={160} truncate>{rec.issuedBy}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{formatDate(rec.issuedDate)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{formatDate(rec.expiryDate)}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={rec.daysLeft <= 30 ? 'red' : rec.daysLeft <= 90 ? 'orange' : 'teal'}
|
||||
variant="light"
|
||||
size="xs"
|
||||
>
|
||||
{rec.daysLeft}d
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={STATUS_COLOR[rec.status] ?? 'gray'} variant="light" size="xs">{rec.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => setSelected(rec)}>
|
||||
{rec.status === 'Pending Verification' ? 'Verify' : 'View'}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {records.length} records</Text>
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="dimmed">Data loaded</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<VerificationDrawer
|
||||
record={selected}
|
||||
opened={!!selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onAction={handleAction}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Medical verification"
|
||||
description="Medical certificate verification is not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShip,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type LicenseStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Inspection Pending'
|
||||
| 'Inspection Completed'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Payment Pending'
|
||||
| 'Payment Confirmed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface MtoApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
commercialRegNumber: string;
|
||||
businessAddress: string;
|
||||
bankName: string;
|
||||
capitalAmount: number;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_MTO_APPLICATIONS: MtoApplication[] = [
|
||||
{
|
||||
id: 'MTO-2024-001',
|
||||
companyName: 'Ethio Multimodal Transport PLC',
|
||||
tinNumber: 'TIN-0098231',
|
||||
commercialRegNumber: 'CR-903112',
|
||||
businessAddress: 'Addis Ababa, Bole Sub-city',
|
||||
bankName: 'Commercial Bank of Ethiopia',
|
||||
capitalAmount: 3200000,
|
||||
status: 'Inspection Pending',
|
||||
submittedDate: '2024-03-15',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: 'Awaiting physical inspection of terminal and warehouse facilities.',
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'MTO-2024-002',
|
||||
companyName: 'Blue Nile Logistics & Terminals Ltd',
|
||||
tinNumber: 'TIN-0076543',
|
||||
commercialRegNumber: 'CR-812098',
|
||||
businessAddress: 'Dire Dawa',
|
||||
bankName: 'Awash Bank',
|
||||
capitalAmount: 2750000,
|
||||
status: 'Submitted',
|
||||
submittedDate: '2024-04-05',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: '',
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'MTO-2023-018',
|
||||
companyName: 'Horn of Africa Freight Terminals PLC',
|
||||
tinNumber: 'TIN-0045210',
|
||||
commercialRegNumber: 'CR-701244',
|
||||
businessAddress: 'Addis Ababa, Kirkos Sub-city',
|
||||
bankName: 'Zemen Bank',
|
||||
capitalAmount: 4100000,
|
||||
status: 'Certificate Issued',
|
||||
submittedDate: '2023-09-20',
|
||||
approvalDate: '2023-10-25',
|
||||
expiryDate: '2024-10-25',
|
||||
remarks: 'All requirements verified. Certificate issued.',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
'Inspection Pending': 'grape',
|
||||
'Inspection Completed': 'indigo',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
'Payment Pending': 'grape',
|
||||
'Payment Confirmed': 'indigo',
|
||||
'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: MtoApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
|
||||
{ label: 'Business Address', value: app.businessAddress },
|
||||
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Bank Confirmation & Deposit Evidence', ok: app.docsComplete },
|
||||
{ label: 'Terminal Lease / Title Document', ok: app.docsComplete },
|
||||
{ label: 'Vehicle Registration / Rental Documents', ok: app.docsComplete },
|
||||
{ label: 'Manager CV & Qualification Documents', ok: app.docsComplete },
|
||||
{ label: 'Insurance & Customs Bond Documents', ok: app.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function MtoLicenseQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<MtoApplication[]>(MOCK_MTO_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<MtoApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<MtoApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/mto?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: LicenseStatus =
|
||||
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved — pending payment.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation' || a.status === 'Inspection Pending' || a.status === 'Inspection Completed').length,
|
||||
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/mto-license/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>MTO License Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Multimodal Transport Operator License applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Bank Letter Amount</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/mto-license/${id}`)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const MTO_ICON = IconShip;
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconClipboardCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_MTO_APPLICATIONS, STATUS_COLOR } from './MtoLicenseQueuePage';
|
||||
import type { MtoApplication, LicenseStatus } from './MtoLicenseQueuePage';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeExpiryDate(approvalDate: string): string {
|
||||
const d = new Date(approvalDate);
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const DOCS = [
|
||||
{ key: 'bankLetter', label: 'Bank Confirmation & Deposit Evidence', fileName: 'bank_confirmation.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'terminalDoc', label: 'Terminal Lease / Title Document', fileName: 'terminal_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Registration / Rental Documents', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'managerDocs', label: 'Manager CV & Qualification Documents', fileName: 'manager_docs.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'insuranceDocs', label: 'Insurance & Customs Bond Documents', fileName: 'insurance_bond.pdf', icon: IconShieldCheck, required: true },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
|
||||
];
|
||||
|
||||
export function MtoLicenseReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<MtoApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [app, setApp] = useState<MtoApplication | null>(null);
|
||||
const [status, setStatus] = useState<LicenseStatus>('Submitted');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/logistics-licenses/mto/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_MTO_APPLICATIONS.find((a) => a.id === id) ?? null;
|
||||
setApp(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
|
||||
const isInspection = status === 'Inspection Pending' || status === 'Inspection Completed';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !app) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as LicenseStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks,
|
||||
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
|
||||
} : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Application status updated to ${newStatus}.`);
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Application not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/mto-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/mto-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShip size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{app.companyName}</Title>
|
||||
<Text fz="sm" c="dimmed">{app.id} · Multimodal Transport Operator License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
|
||||
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
|
||||
This application has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Inspection Pending' && (
|
||||
<Alert icon={<IconClipboardCheck size={17} />} color="grape" title="Inspection Required">
|
||||
A physical inspection of the applicant's terminal, warehouse, trucks, office, and equipment is required before this application can proceed. An Inspector must complete the site visit.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Inspection Completed' && (
|
||||
<Alert icon={<IconClipboardCheck size={17} />} color="indigo" title="Inspection Completed">
|
||||
The physical inspection of the applicant's terminal, warehouse, trucks, office, and equipment was completed by an Inspector.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">Take Action</Text>
|
||||
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
|
||||
Update Status
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Company Name" value={app.companyName} />
|
||||
<InfoRow label="TIN Number" value={app.tinNumber} />
|
||||
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
|
||||
<InfoRow label="Business Address" value={app.businessAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Financial Capacity</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Bank Name" value={app.bankName} />
|
||||
<InfoRow label="Paid-up Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{isInspection && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Inspection</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Inspection Status" value={status} />
|
||||
<InfoRow label="Scope" value="Terminal, Warehouse, Trucks, Office, Equipment" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
const ok = app.docsComplete;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
|
||||
<DocIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="xs">
|
||||
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
{ok ? (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={app.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
|
||||
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
|
||||
<IconCertificate size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="md" c="teal.7">Issued Certificate — Multimodal Transport Operator License</Text>
|
||||
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Multimodal Transport Operator License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
|
||||
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={['Under Review', 'Under Evaluation', 'Inspection Pending', 'Inspection Completed', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
{(selectedStatus === 'Inspection Pending' || selectedStatus === 'Inspection Completed') && (
|
||||
<Alert icon={<IconClipboardCheck size={16} />} color="grape" variant="light">
|
||||
{selectedStatus === 'Inspection Pending'
|
||||
? 'A physical inspection of the terminal, warehouse, trucks, office, and equipment is required. An Inspector must complete this before further processing.'
|
||||
: 'This confirms the physical inspection of the terminal, warehouse, trucks, office, and equipment was completed by an Inspector.'}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the applicant..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -19,400 +17,365 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconCreditCard,
|
||||
IconEdit,
|
||||
IconId,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface CertFee {
|
||||
id: string;
|
||||
certType: string;
|
||||
icon: typeof IconBook2;
|
||||
color: string;
|
||||
description: string;
|
||||
fees: { label: string; amount: number; editable: boolean }[];
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Licence fee configuration.
|
||||
*
|
||||
* The amounts live on the licence type itself, which is what the workflow
|
||||
* reads when it raises a payment — so what is edited here is the same value
|
||||
* the applicant is charged, not a parallel copy of it.
|
||||
*
|
||||
* Saving is guarded server-side by `can:update:license-type`; an officer
|
||||
* without that permission can read the figures but the save is refused.
|
||||
*/
|
||||
|
||||
function feeText(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return '—';
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
instructions: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
const INITIAL_CERT_FEES: CertFee[] = [
|
||||
{
|
||||
id: 'seaman-book',
|
||||
certType: 'Seaman Book',
|
||||
icon: IconBook2,
|
||||
color: 'blue',
|
||||
description: 'Official EMA seafarer identification book — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 500, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'btc',
|
||||
certType: 'Basic Training Certificate (BTC)',
|
||||
icon: IconCertificate,
|
||||
color: 'teal',
|
||||
description: 'EMA-issued BTC certifying all 5 basic safety training courses — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 300, editable: true },
|
||||
{ label: 'Document Verification Fee',amount: 100, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'bsid',
|
||||
certType: 'BSID (Biometric Seafarer ID)',
|
||||
icon: IconId,
|
||||
color: 'violet',
|
||||
description: 'Biometric Seafarer Identity Document — valid 5 years',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 100, editable: true },
|
||||
{ label: 'Card Production Fee', amount: 150, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'certificate',
|
||||
certType: 'Certificate (CoC / CoP)',
|
||||
icon: IconShieldCheck,
|
||||
color: 'orange',
|
||||
description: 'Certificate of Competency or Certificate of Proficiency under STCW',
|
||||
fees: [
|
||||
{ label: 'Application Fee', amount: 800, editable: true },
|
||||
{ label: 'Examination Fee', amount: 400, editable: true },
|
||||
{ label: 'Certificate Issuance Fee', amount: 200, editable: true },
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_PAYMENT_METHODS: PaymentMethod[] = [
|
||||
{
|
||||
id: 'cbe',
|
||||
name: 'Commercial Bank of Ethiopia',
|
||||
shortName: 'CBE',
|
||||
accountNumber: '1000123456789',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Transfer the exact fee amount. Use your full name as the transfer description.',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'telebirr',
|
||||
name: 'Telebirr (Ethio Telecom)',
|
||||
shortName: 'Telebirr',
|
||||
accountNumber: '+251 11 551 0000',
|
||||
accountName: 'EMA Maritime Authority',
|
||||
instructions: 'Send to Telebirr number. Screenshot your confirmation and upload it with your application.',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fee edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function FeeEditModal({
|
||||
cert,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
cert: CertFee | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, fees: CertFee['fees']) => void;
|
||||
}) {
|
||||
const [fees, setFees] = useState<CertFee['fees']>(cert?.fees ?? []);
|
||||
|
||||
const handleOpen = () => setFees(cert?.fees ?? []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Fees — {cert?.certType}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened) handleOpen(); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
Changes take effect on new applications immediately. Existing applications retain the fee at time of submission.
|
||||
</Alert>
|
||||
{fees.map((fee, i) => (
|
||||
<Group key={fee.label} gap="sm" align="flex-end">
|
||||
<TextInput label="Fee Label" value={fee.label} disabled style={{ flex: 1 }} size="sm" />
|
||||
<NumberInput
|
||||
label="Amount (ETB)"
|
||||
value={fee.amount}
|
||||
min={0}
|
||||
step={50}
|
||||
style={{ width: rem(140) }}
|
||||
size="sm"
|
||||
disabled={!fee.editable}
|
||||
onChange={(v) =>
|
||||
setFees((prev) => prev.map((f, idx) => idx === i ? { ...f, amount: Number(v) || 0 } : f))
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider />
|
||||
<Paper withBorder radius="md" p="sm" bg="gray.0">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" fw={700}>Total</Text>
|
||||
<Text fz="sm" fw={700} c="blue">ETB {fees.reduce((s, f) => s + f.amount, 0).toFixed(2)}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(cert!.id, fees); onClose(); }}>
|
||||
Save Fees
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment method edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function MethodEditModal({
|
||||
method,
|
||||
opened,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
method: PaymentMethod | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updated: PaymentMethod) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<PaymentMethod>(method ?? INITIAL_PAYMENT_METHODS[0]);
|
||||
const set = (k: keyof PaymentMethod, v: string) => setForm((p) => ({ ...p, [k]: v }));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconCreditCard size={18} /><Text fw={700}>Edit Payment Method — {method?.shortName}</Text></Group>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
onTransitionEnd={() => { if (opened && method) setForm(method); }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Account Number / Phone" value={form.accountNumber} onChange={(e) => set('accountNumber', e.currentTarget.value)} />
|
||||
<TextInput label="Account Name" value={form.accountName} onChange={(e) => set('accountName', e.currentTarget.value)} />
|
||||
<TextInput label="Instructions (shown to applicant)" value={form.instructions} onChange={(e) => set('instructions', e.currentTarget.value)} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button leftSection={<IconCheck size={15} />} onClick={() => { onSave(form); onClose(); }}>Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function PaymentConfigPage() {
|
||||
const [certFees, setCertFees] = useState<CertFee[]>(INITIAL_CERT_FEES);
|
||||
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
const { data, isLoading, error } = useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
|
||||
const [editingCert, setEditingCert] = useState<CertFee | null>(null);
|
||||
const [editingMethod, setEditingMethod] = useState<PaymentMethod | null>(null);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCombined = certFees
|
||||
.filter((c) => c.enabled)
|
||||
.flatMap((c) => c.fees)
|
||||
.reduce((s, f) => s + f.amount, 0);
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="Could not load licence types"
|
||||
>
|
||||
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSaveFees = (id: string, fees: CertFee['fees']) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, fees } : c));
|
||||
notify.success('Fees updated successfully.');
|
||||
};
|
||||
|
||||
const handleToggleCert = (id: string) => {
|
||||
setCertFees((prev) => prev.map((c) => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
||||
};
|
||||
|
||||
const handleSaveMethod = (updated: PaymentMethod) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === updated.id ? updated : m));
|
||||
notify.success('Payment method updated.');
|
||||
};
|
||||
|
||||
const handleToggleMethod = (id: string) => {
|
||||
setMethods((prev) => prev.map((m) => m.id === id ? { ...m, enabled: !m.enabled } : m));
|
||||
};
|
||||
const types = [...(data?.items ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment Configuration</Title>
|
||||
<Text fz="sm" c="dimmed">Manage certificate fees and accepted payment methods shown to applicants</Text>
|
||||
<Title order={3}>Payment configuration</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
What each licence costs. Applicants are charged after approval, and
|
||||
the amount is fixed onto the application at that moment.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge size="lg" variant="light" color="blue">
|
||||
Combined Total: ETB {totalCombined.toFixed(2)}
|
||||
</Badge>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
<IconCreditCard size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
|
||||
{/* Fee summary table */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Text fw={700} fz="md">Certificate Fees</Text>
|
||||
<Text fz="xs" c="dimmed">Click Edit to change fee amounts</Text>
|
||||
</Group>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
A change applies to applications approved from now on. Anything
|
||||
already approved keeps the amount it was quoted, so an edit here can
|
||||
never alter what an applicant has already been asked to pay.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Table highlightOnHover verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Certificate Type', 'Fee Breakdown', 'Total', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certFees.map((cert) => {
|
||||
const CertIcon = cert.icon;
|
||||
const total = cert.fees.reduce((s, f) => s + f.amount, 0);
|
||||
return (
|
||||
<Table.Tr key={cert.id} style={{ opacity: cert.enabled ? 1 : 0.5 }}>
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Licence type</Table.Th>
|
||||
<Table.Th>New application</Table.Th>
|
||||
<Table.Th>Renewal</Table.Th>
|
||||
<Table.Th>Charged?</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{types.map((type) => (
|
||||
<Table.Tr key={type.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="md" variant="light" color={cert.color} radius="md">
|
||||
<CertIcon size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.certType}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
{cert.fees.map((f) => (
|
||||
<Group key={f.label} gap={6}>
|
||||
<Text fz="xs" c="dimmed">{f.label}:</Text>
|
||||
<Text fz="xs" fw={500}>ETB {f.amount.toFixed(2)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={700} c={cert.enabled ? 'blue.7' : 'dimmed'}>ETB {total.toFixed(2)}</Text>
|
||||
{type.feeNewApplication === null ? (
|
||||
// No charge at all, so "same as new" would be noise.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
(same as new)
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Switch
|
||||
checked={cert.enabled}
|
||||
onChange={() => handleToggleCert(cert.id)}
|
||||
size="sm"
|
||||
color="teal"
|
||||
label={cert.enabled ? 'Active' : 'Disabled'}
|
||||
/>
|
||||
{type.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
Not charged
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingCert(cert)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => setEditing(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}><Text fz="sm" fw={700} ta="right">Grand Total (all active)</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={800} c="blue">ETB {totalCombined.toFixed(2)}</Text></Table.Td>
|
||||
<Table.Td colSpan={2} />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Paper>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Card>
|
||||
|
||||
{/* Payment methods */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} fz="md" mb="lg">Accepted Payment Methods</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{methods.map((method) => (
|
||||
<Card key={method.id} withBorder radius="md" p="md" style={{ opacity: method.enabled ? 1 : 0.6 }}>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" variant="light" color={method.enabled ? 'blue' : 'gray'} radius="md">
|
||||
<IconCreditCard size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{method.name}</Text>
|
||||
<Badge size="xs" variant="light" color={method.enabled ? 'teal' : 'gray'}>
|
||||
{method.enabled ? 'Active' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Switch checked={method.enabled} onChange={() => handleToggleMethod(method.id)} size="sm" color="teal" />
|
||||
<ActionIcon variant="light" color="blue" size="md" onClick={() => setEditingMethod(method)}>
|
||||
<IconEdit size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account / Number</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Account Name</Text>
|
||||
<Text fz="xs" fw={600}>{method.accountName}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Text fz="xs" c="dimmed" mt="sm" lh={1.4}>{method.instructions}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||
|
||||
{/* Fee edit modal */}
|
||||
<FeeEditModal
|
||||
cert={editingCert}
|
||||
opened={!!editingCert}
|
||||
onClose={() => setEditingCert(null)}
|
||||
onSave={handleSaveFees}
|
||||
/>
|
||||
|
||||
{/* Method edit modal */}
|
||||
<MethodEditModal
|
||||
method={editingMethod}
|
||||
opened={!!editingMethod}
|
||||
onClose={() => setEditingMethod(null)}
|
||||
onSave={handleSaveMethod}
|
||||
/>
|
||||
<FeeEditModal licenseType={editing} onClose={() => setEditing(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How payments are actually collected. Read-only on purpose: these come from
|
||||
* the payment service's environment rather than the database, so rendering
|
||||
* them as editable fields would be a lie.
|
||||
*/
|
||||
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="xs">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color="gray">
|
||||
<IconLock size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Payment gateway
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Set by the payment service environment — not editable here.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light">Telebirr</Badge>
|
||||
{bypassEnabled ? (
|
||||
<Tooltip label="ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.">
|
||||
<Badge variant="light" color="orange">
|
||||
Test bypass enabled
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
Test bypass off
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeEditModal({
|
||||
licenseType,
|
||||
onClose,
|
||||
}: {
|
||||
licenseType: LicenseType | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
||||
const [newFee, setNewFee] = useState<number | ''>('');
|
||||
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
||||
const [currency, setCurrency] = useState('ETB');
|
||||
// Held separately from an empty amount: "carries no fee" and "same as new"
|
||||
// are real configuration states, not blank fields.
|
||||
const [chargeable, setChargeable] = useState(true);
|
||||
const [sameAsNew, setSameAsNew] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!licenseType) return;
|
||||
setChargeable(licenseType.feeNewApplication !== null);
|
||||
setNewFee(
|
||||
licenseType.feeNewApplication === null
|
||||
? ''
|
||||
: Number(licenseType.feeNewApplication),
|
||||
);
|
||||
setSameAsNew(licenseType.feeRenewal === null);
|
||||
setRenewalFee(
|
||||
licenseType.feeRenewal === null ? '' : Number(licenseType.feeRenewal),
|
||||
);
|
||||
setCurrency(licenseType.feeCurrency || 'ETB');
|
||||
}, [licenseType]);
|
||||
|
||||
async function save() {
|
||||
if (!licenseType) return;
|
||||
if (chargeable && newFee === '') {
|
||||
notify.error('Enter a new-application fee, or turn off "carries a fee".');
|
||||
return;
|
||||
}
|
||||
if (chargeable && !sameAsNew && renewalFee === '') {
|
||||
notify.error('Enter a renewal fee, or charge renewal at the same rate.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateFees({
|
||||
id: licenseType.id,
|
||||
feeNewApplication: chargeable ? Number(newFee) : null,
|
||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||
feeCurrency: currency.trim() || 'ETB',
|
||||
}).unwrap();
|
||||
notify.success(`${localized(licenseType.name)} fees updated.`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not save the fees'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={!!licenseType}
|
||||
onClose={onClose}
|
||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
{licenseType && (
|
||||
<Stack gap="md">
|
||||
{!licenseType.issuesCertificate && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="gray"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
This licence type concludes with an EMA decision and never
|
||||
reaches a payment stage, so a fee set here stays unused until
|
||||
that changes.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={chargeable}
|
||||
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
||||
label="This licence carries a fee"
|
||||
description="Turn off for licence types applicants are never charged for."
|
||||
/>
|
||||
|
||||
{chargeable && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="New application fee"
|
||||
value={newFee}
|
||||
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={sameAsNew}
|
||||
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
||||
label="Charge renewal at the same rate"
|
||||
description="Turn off to set a separate renewal fee."
|
||||
/>
|
||||
|
||||
{!sameAsNew && (
|
||||
<NumberInput
|
||||
label="Renewal fee"
|
||||
value={renewalFee}
|
||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
thousandSeparator=","
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Currency"
|
||||
value={currency}
|
||||
onChange={(e) =>
|
||||
setCurrency(e.currentTarget.value.toUpperCase())
|
||||
}
|
||||
maxLength={8}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} loading={isLoading}>
|
||||
Save fees
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentConfigPage;
|
||||
|
||||
@@ -1,325 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Loader,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEye,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Seafarer {
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
nationality: string;
|
||||
dob: string;
|
||||
rank: string;
|
||||
seamanBookNo: string;
|
||||
seamanBookExpiry: string;
|
||||
btcNo: string | null;
|
||||
bsidNo: string | null;
|
||||
medicalExpiry: string;
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired';
|
||||
cocCerts: { type: string; no: string; expiry: string }[];
|
||||
status: 'Active' | 'Inactive' | 'Suspended';
|
||||
firstName: string;
|
||||
middleName?: string;
|
||||
lastName: string;
|
||||
gender?: string;
|
||||
type?: string;
|
||||
isComplete?: boolean;
|
||||
profession?: { name?: { en?: string } };
|
||||
address?: { idNumber?: string; nationality?: string; primaryPhoneNumber?: string };
|
||||
}
|
||||
|
||||
const MOCK_SEAFARERS: Seafarer[] = [
|
||||
{
|
||||
id: 'SF-2024-0001', name: 'Abebe Girma', nationality: 'Ethiopian', dob: '1990-04-12',
|
||||
rank: 'Officer of the Watch', seamanBookNo: 'SB-2024-0001', seamanBookExpiry: '2029-03-14',
|
||||
btcNo: 'BTC-2024-0001', bsidNo: 'BSID-2024-0001',
|
||||
medicalExpiry: '2026-03-14', medicalStatus: 'Expiring',
|
||||
cocCerts: [{ type: 'CoC — STCW II/1', no: 'COC-2023-0042', expiry: '2028-06-20' }],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2024-0002', name: 'Sara Tadesse', nationality: 'Ethiopian', dob: '1994-08-05',
|
||||
rank: 'Able Seaman', seamanBookNo: 'SB-2024-0002', seamanBookExpiry: '2029-05-10',
|
||||
btcNo: 'BTC-2024-0002', bsidNo: null,
|
||||
medicalExpiry: '2027-05-10', medicalStatus: 'Valid',
|
||||
cocCerts: [],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2024-0003', name: 'Dawit Bekele', nationality: 'Ethiopian', dob: '1988-01-22',
|
||||
rank: 'Chief Engineer', seamanBookNo: 'SB-2024-0003', seamanBookExpiry: '2028-08-20',
|
||||
btcNo: 'BTC-2024-0003', bsidNo: 'BSID-2024-0003',
|
||||
medicalExpiry: '2025-08-20', medicalStatus: 'Expired',
|
||||
cocCerts: [
|
||||
{ type: 'CoC — STCW III/1', no: 'COC-2022-0018', expiry: '2027-08-20' },
|
||||
{ type: 'CoC — STCW III/2', no: 'COC-2023-0031', expiry: '2028-04-15' },
|
||||
],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2023-0088', name: 'Tekle Haile', nationality: 'Ethiopian', dob: '1985-11-30',
|
||||
rank: 'Master', seamanBookNo: 'SB-2023-0088', seamanBookExpiry: '2028-01-15',
|
||||
btcNo: 'BTC-2023-0088', bsidNo: 'BSID-2023-0088',
|
||||
medicalExpiry: '2026-02-28', medicalStatus: 'Expiring',
|
||||
cocCerts: [{ type: 'CoC — STCW II/2 Master', no: 'COC-2021-0005', expiry: '2026-07-10' }],
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'SF-2022-0210', name: 'Yonas Tesfaye', nationality: 'Ethiopian', dob: '1992-06-14',
|
||||
rank: 'Deck Rating', seamanBookNo: 'SB-2022-0210', seamanBookExpiry: '2027-06-14',
|
||||
btcNo: null, bsidNo: null,
|
||||
medicalExpiry: '2026-01-30', medicalStatus: 'Expiring',
|
||||
cocCerts: [],
|
||||
status: 'Inactive',
|
||||
},
|
||||
];
|
||||
|
||||
const RANK_OPTIONS = ['All', 'Master', 'Chief Engineer', 'Officer of the Watch', 'Able Seaman', 'Deck Rating'];
|
||||
const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
|
||||
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
|
||||
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boolean; onClose: () => void }) {
|
||||
if (!sf) return null;
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Group gap="xs"><IconUser size={17} /><Text fw={700}>{sf.name} — {sf.id}</Text></Group>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Personal</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Full Name</Text><Text fz="xs" fw={600}>{sf.name}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>Documents</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">Seaman Book</Text><Text fz="xs" fw={600}>{sf.seamanBookNo}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">SB Expiry</Text><Text fz="xs">{sf.seamanBookExpiry}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BTC No.</Text><Text fz="xs">{sf.btcNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between"><Text fz="xs" c="dimmed">BSID No.</Text><Text fz="xs">{sf.bsidNo ?? '—'}</Text></Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Medical Expiry</Text>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalExpiry} ({sf.medicalStatus})</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{sf.cocCerts.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fz="xs" fw={700} c="dimmed" mb="xs" style={{ textTransform: 'uppercase' }}>CoC / CoP Certificates</Text>
|
||||
<Table fz="xs" verticalSpacing="xs">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Type', 'Certificate No.', 'Expiry'].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(10), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sf.cocCerts.map((c) => (
|
||||
<Table.Tr key={c.no}>
|
||||
<Table.Td><Text fz="xs" fw={600}>{c.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" c="blue.7">{c.no}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{c.expiry}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Registered seafarer profiles, read from the real profiles endpoint.
|
||||
*
|
||||
* This previously listed a hardcoded roster, so the registry showed seafarers
|
||||
* who had never registered.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [rankFilter, setRankFilter] = useState('All');
|
||||
const [statusFilter, setStatusFilter] = useState('All');
|
||||
const [medicalFilter, setMedicalFilter] = useState('All');
|
||||
const [selected, setSelected] = useState<Seafarer | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(true);
|
||||
|
||||
const filtered = MOCK_SEAFARERS.filter((sf) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || sf.name.toLowerCase().includes(q) || sf.id.toLowerCase().includes(q) || sf.seamanBookNo.toLowerCase().includes(q);
|
||||
const matchRank = rankFilter === 'All' || sf.rank === rankFilter;
|
||||
const matchStatus = statusFilter === 'All' || sf.status === statusFilter;
|
||||
const matchMed = medicalFilter === 'All' || sf.medicalStatus === medicalFilter;
|
||||
return matchSearch && matchRank && matchStatus && matchMed;
|
||||
const { data, isLoading } = useApiQuery<{ total: number; items: ProfileRow[] }>({
|
||||
url: '/profiles',
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
|
||||
const KPI = [
|
||||
{ label: 'Total Seafarers', value: MOCK_SEAFARERS.length, color: 'blue', icon: IconUsers },
|
||||
{ label: 'Active', value: MOCK_SEAFARERS.filter((s) => s.status === 'Active').length, color: 'teal', icon: IconUser },
|
||||
{ label: 'Medical Expiring',value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expiring').length, color: 'orange', icon: IconHeart },
|
||||
{ label: 'Medical Expired', value: MOCK_SEAFARERS.filter((s) => s.medicalStatus === 'Expired').length, color: 'red', icon: IconHeart },
|
||||
{ label: 'With CoC', value: MOCK_SEAFARERS.filter((s) => s.cocCerts.length > 0).length, color: 'violet', icon: IconShieldCheck },
|
||||
{ label: 'Without BSID', value: MOCK_SEAFARERS.filter((s) => !s.bsidNo).length, color: 'yellow', icon: IconId },
|
||||
];
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
const term = search.toLowerCase();
|
||||
return [p.firstName, p.middleName, p.lastName, p.address?.idNumber]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer Registry</Title>
|
||||
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
|
||||
</div>
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} registered profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name or ID number"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* KPIs */}
|
||||
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
|
||||
{KPI.map((k) => {
|
||||
const KIcon = k.icon;
|
||||
return (
|
||||
<Card key={k.label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={28} radius="sm" color={k.color} variant="light"><KIcon size={14} /></ThemeIcon>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fz="lg" fw={800} lh={1}>{k.value}</Text>
|
||||
<Text fz="xs" c="dimmed" lh={1.2} style={{ lineHeight: 1.2 }}>{k.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group mb="sm" gap="sm" justify="space-between">
|
||||
<TextInput
|
||||
placeholder="Search by name, seafarer ID, or seaman book…"
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
rightSection={filtersOpen ? <IconChevronUp size={12} /> : <IconChevronDown size={12} />}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Collapse in={filtersOpen}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm" mb="md">
|
||||
<Select label="Rank" data={RANK_OPTIONS} value={rankFilter} onChange={(v) => setRankFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Status" data={STATUS_OPTIONS} value={statusFilter} onChange={(v) => setStatusFilter(v ?? 'All')} size="sm" />
|
||||
<Select label="Medical Status" data={MEDICAL_OPTIONS} value={medicalFilter} onChange={(v) => setMedicalFilter(v ?? 'All')} size="sm" />
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
</Collapse>
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm">{filtered.length} seafarer(s) found</Text>
|
||||
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['Seafarer ID', 'Name', 'Rank', 'Seaman Book', 'BTC', 'BSID', 'Medical', 'CoC/CoP', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((sf) => (
|
||||
<Table.Tr key={sf.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{sf.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={600}>{sf.name}</Text><Text fz="xs" c="dimmed">{sf.dob}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{sf.rank}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<IconBook2 size={11} color="var(--mantine-color-blue-6)" />
|
||||
<Text fz="xs">{sf.seamanBookNo}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.btcNo
|
||||
? <Group gap={4}><IconCertificate size={11} color="var(--mantine-color-teal-6)" /><Text fz="xs">{sf.btcNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.bsidNo
|
||||
? <Group gap={4}><IconId size={11} color="var(--mantine-color-violet-6)" /><Text fz="xs">{sf.bsidNo}</Text></Group>
|
||||
: <Badge color="red" variant="light" size="xs">Missing</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={MEDICAL_COLOR[sf.medicalStatus]} variant="light" size="xs">{sf.medicalStatus}</Badge>
|
||||
<Text fz="xs" c="dimmed">{sf.medicalExpiry}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{sf.cocCerts.length > 0
|
||||
? <Badge color="violet" variant="light" size="xs">{sf.cocCerts.length} cert(s)</Badge>
|
||||
: <Text fz="xs" c="dimmed">—</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>
|
||||
<IconEye size={13} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed" fz="sm">No seafarers match your search.</Text>
|
||||
</Table.Td>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Profession</Table.Th>
|
||||
<Table.Th>ID number</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<DetailModal sf={selected} opened={!!selected} onClose={() => setSelected(null)} />
|
||||
</Stack>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((p) => (
|
||||
<Table.Tr key={p.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{p.profession?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeafarerRegistryPage;
|
||||
|
||||
@@ -1,377 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconUsers,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SeamanBookApp {
|
||||
id: string;
|
||||
seafarerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
mobile: string;
|
||||
nationality: string;
|
||||
submitted: string;
|
||||
status: 'Pending' | 'Under Review' | 'Awaiting Docs' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
medicalStatus: 'Valid' | 'Expiring' | 'Expired' | 'Missing';
|
||||
bstComplete: boolean;
|
||||
bstCount: number;
|
||||
docsComplete: boolean;
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
const MOCK_APPS: SeamanBookApp[] = [
|
||||
{ id: 'SB-APP-2024-001', seafarerId: 'SF-2024-0001', name: 'Abebe Girma', email: 'abebe.g@email.com', mobile: '+251 911 234 567', nationality: 'Ethiopian', submitted: '2024-05-10', status: 'Under Review', medicalStatus: 'Expiring', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All documents submitted. Under initial review.' },
|
||||
{ id: 'SB-APP-2024-002', seafarerId: 'SF-2024-0002', name: 'Sara Tadesse', email: 'sara.t@email.com', mobile: '+251 922 345 678', nationality: 'Ethiopian', submitted: '2024-05-12', status: 'Awaiting Docs', medicalStatus: 'Valid', bstComplete: false, bstCount: 3, docsComplete: false, remarks: 'Missing EFA and PSSR certificates.' },
|
||||
{ id: 'SB-APP-2024-003', seafarerId: 'SF-2024-0003', name: 'Dawit Bekele', email: 'dawit.b@email.com', mobile: '+251 933 456 789', nationality: 'Ethiopian', submitted: '2024-05-14', status: 'Under Review', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'Pending document authenticity check.' },
|
||||
{ id: 'SB-APP-2024-004', seafarerId: 'SF-2024-0004', name: 'Hana Mulugeta', email: 'hana.m@email.com', mobile: '+251 944 567 890', nationality: 'Ethiopian', submitted: '2024-05-15', status: 'Correction Required', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: false, remarks: 'National ID scan is unclear. Please resubmit.' },
|
||||
{ id: 'SB-APP-2024-005', seafarerId: 'SF-2024-0005', name: 'Yonas Tesfaye', email: 'yonas.t@email.com', mobile: '+251 955 678 901', nationality: 'Ethiopian', submitted: '2024-05-16', status: 'Pending', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: '' },
|
||||
{ id: 'SB-APP-2024-006', seafarerId: 'SF-2024-0006', name: 'Meron Alemu', email: 'meron.a@email.com', mobile: '+251 966 789 012', nationality: 'Ethiopian', submitted: '2024-05-18', status: 'Approved', medicalStatus: 'Valid', bstComplete: true, bstCount: 5, docsComplete: true, remarks: 'All verified. Ready to print.' },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray', 'Under Review': 'yellow', 'Awaiting Docs': 'orange',
|
||||
Approved: 'teal', Rejected: 'red', 'Correction Required': 'red',
|
||||
};
|
||||
|
||||
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red', Missing: 'red' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function AppDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: SeamanBookApp | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'correction') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Application ${app.id}`}
|
||||
position="right"
|
||||
size="lg"
|
||||
padding="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconEye size={15} />}
|
||||
fullWidth
|
||||
onClick={() => { onClose(); onFullReview(app.id); }}
|
||||
>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
{/* Seafarer info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Seafarer Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
['Seafarer ID', app.seafarerId],
|
||||
['Full Name', app.name],
|
||||
['Email', app.email],
|
||||
['Mobile', app.mobile],
|
||||
['Nationality', app.nationality],
|
||||
['Submitted', app.submitted],
|
||||
].map(([label, value]) => (
|
||||
<Box key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm">{value}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Eligibility Verification</Text>
|
||||
<Stack gap="xs">
|
||||
{[
|
||||
{ label: 'Seafarer Profile Complete', ok: true, icon: IconUsers },
|
||||
{ label: 'National ID / Fayda Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
{ label: `Medical Certificate (${app.medicalStatus})`, ok: app.medicalStatus === 'Valid', icon: IconHeart },
|
||||
{ label: `Basic Safety Training (${app.bstCount}/5)`, ok: app.bstComplete, icon: IconShieldCheck },
|
||||
{ label: 'All Documents Uploaded', ok: app.docsComplete, icon: IconFileDescription },
|
||||
].map(({ label, ok, icon: Icon }) => (
|
||||
<Group key={label} gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" variant={ok ? 'filled' : 'light'} color={ok ? 'teal' : 'red'}>
|
||||
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={ok ? undefined : 'dimmed'}>{label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Current status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="sm" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{/* Officer remarks */}
|
||||
<Textarea
|
||||
label="Officer Remarks"
|
||||
placeholder="Add notes or reason for decision…"
|
||||
minRows={3}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action buttons */}
|
||||
<Group grow>
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<IconCircleCheck size={15} />}
|
||||
onClick={() => setConfirmModal('approve')}
|
||||
disabled={!app.docsComplete || !app.bstComplete || app.medicalStatus === 'Expired' || app.medicalStatus === 'Missing'}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
variant="light"
|
||||
leftSection={<IconAlertCircle size={15} />}
|
||||
onClick={() => setConfirmModal('correction')}
|
||||
>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={15} />}
|
||||
onClick={() => setConfirmModal('reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={`Confirm ${confirmModal === 'approve' ? 'Approval' : confirmModal === 'reject' ? 'Rejection' : 'Correction Request'}`}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? 'Are you sure you want to approve this Seaman Book application? The seafarer will be notified.'
|
||||
: confirmModal === 'reject'
|
||||
? 'Are you sure you want to reject this application? Please ensure you have added remarks explaining the reason.'
|
||||
: 'A correction request will be sent to the seafarer with your remarks. Are you sure?'}
|
||||
</Text>
|
||||
{!remarks && confirmModal !== 'approve' && (
|
||||
<Text fz="xs" c="red" mb="sm">Please add remarks before proceeding.</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
onClick={() => submit(confirmModal!)}
|
||||
disabled={!remarks && confirmModal !== 'approve'}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function SeamanBookQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<SeamanBookApp[]>(MOCK_APPS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
pending: apps.filter((a) => a.status === 'Pending' || a.status === 'Under Review').length,
|
||||
awaitingDocs: apps.filter((a) => a.status === 'Awaiting Docs').length,
|
||||
approved: apps.filter((a) => a.status === 'Approved').length,
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.seafarerId.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Seaman Book Applications</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process seafarer Seaman Book applications</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'blue', icon: IconBook2 },
|
||||
{ label: 'Under Review', value: stats.pending, color: 'yellow', icon: IconClock },
|
||||
{ label: 'Awaiting Docs', value: stats.awaitingDocs,color: 'orange', icon: IconFileDescription },
|
||||
{ label: 'Approved', value: stats.approved, color: 'teal', icon: IconCircleCheck },
|
||||
].map(({ label, value, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md"><Icon size={18} /></ThemeIcon>
|
||||
<div><Text fz="xl" fw={700} lh={1}>{value}</Text><Text fz="xs" c="dimmed">{label}</Text></div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Application Queue</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Search by name, App ID or Seafarer ID…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(280) }}
|
||||
rightSection={search ? <ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon> : null}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Status"
|
||||
data={['Pending', 'Under Review', 'Awaiting Docs', 'Correction Required', 'Approved', 'Rejected']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
size="sm"
|
||||
style={{ width: rem(180) }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Box py="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm"><IconBook2 size={22} /></ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No applications found</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Table highlightOnHover striped verticalSpacing="sm" fz="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Seafarer', 'Submitted', 'Medical', 'BST', 'Documents', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)', whiteSpace: 'nowrap' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" fw={500}>{app.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{app.seafarerId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
|
||||
<Table.Td><Badge color={MEDICAL_COLOR[app.medicalStatus]} variant="light" size="xs">{app.medicalStatus}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.bstComplete ? 'teal' : 'red'} variant="light" size="xs">
|
||||
{app.bstCount}/5
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.docsComplete ? 'teal' : 'orange'} variant="light" size="xs">
|
||||
{app.docsComplete ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="xs">{app.status}</Badge></Table.Td>
|
||||
<Table.Td>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => navigate(`/applications/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 && (
|
||||
<Group px="md" py="sm" justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Showing {filtered.length} of {apps.length} applications</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Seaman Book queue"
|
||||
description="Seaman Book applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeamanBookQueuePage;
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShip,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type LicenseStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Payment Pending'
|
||||
| 'Payment Confirmed'
|
||||
| 'Certificate Issued';
|
||||
|
||||
export interface ShippingAgentApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
commercialRegNumber: string;
|
||||
businessAddress: string;
|
||||
bankName: string;
|
||||
capitalAmount: number;
|
||||
status: LicenseStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
expiryDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_SA_APPLICATIONS: ShippingAgentApplication[] = [
|
||||
{
|
||||
id: 'SA-2024-001',
|
||||
companyName: 'Blue Nile Shipping Agency PLC',
|
||||
tinNumber: 'TIN-0098765',
|
||||
commercialRegNumber: 'CR-902001',
|
||||
businessAddress: 'Addis Ababa, Bole Sub-city',
|
||||
bankName: 'Commercial Bank of Ethiopia',
|
||||
capitalAmount: 1650000,
|
||||
status: 'Under Evaluation',
|
||||
submittedDate: '2024-03-12',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: 'Terminal agreement and employee documents under review.',
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'SA-2024-002',
|
||||
companyName: 'Red Sea Maritime Agents Ltd',
|
||||
tinNumber: 'TIN-0043218',
|
||||
commercialRegNumber: 'CR-770512',
|
||||
businessAddress: 'Dire Dawa',
|
||||
bankName: 'Dashen Bank',
|
||||
capitalAmount: 1250000,
|
||||
status: 'Submitted',
|
||||
submittedDate: '2024-04-05',
|
||||
approvalDate: null,
|
||||
expiryDate: null,
|
||||
remarks: '',
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'SA-2023-018',
|
||||
companyName: 'Horn of Africa Shipping Agency PLC',
|
||||
tinNumber: 'TIN-0021456',
|
||||
commercialRegNumber: 'CR-661120',
|
||||
businessAddress: 'Addis Ababa, Kirkos Sub-city',
|
||||
bankName: 'Zemen Bank',
|
||||
capitalAmount: 1950000,
|
||||
status: 'Certificate Issued',
|
||||
submittedDate: '2023-10-11',
|
||||
approvalDate: '2023-11-08',
|
||||
expiryDate: '2024-11-08',
|
||||
remarks: 'All requirements verified. Certificate issued.',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
'Payment Pending': 'grape',
|
||||
'Payment Confirmed': 'indigo',
|
||||
'Certificate Issued': 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: ShippingAgentApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Certificate Issued';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Commercial Reg. No.', value: app.commercialRegNumber },
|
||||
{ label: 'Business Address', value: app.businessAddress },
|
||||
{ label: 'Bank Letter Amount', value: `${app.capitalAmount.toLocaleString()} ETB` },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Bank Letter (≥ 1.2M ETB)', ok: app.docsComplete },
|
||||
{ label: 'Shipping Company Agreement', ok: app.docsComplete },
|
||||
{ label: 'Vehicle Libre / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'Office Title Deed / Rental Agreement', ok: app.docsComplete },
|
||||
{ label: 'Terminal Agreement / Title Deed', ok: app.docsComplete },
|
||||
{ label: '4 Employee Profiles (Booking Clerk, Canvasser, Admin, CEO)', ok: app.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export function ShippingAgentLicenseQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<ShippingAgentApplication[]>(MOCK_SA_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<ShippingAgentApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<ShippingAgentApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-licenses/shipping-agent?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: LicenseStatus =
|
||||
action === 'approve' ? 'Payment Pending' : action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved — pending payment.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
|
||||
issued: apps.filter((a) => a.status === 'Certificate Issued').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.capitalAmount.toLocaleString()} ETB</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/shipping-agent-license/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Shipping Agent License Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Shipping Agent License applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Certificates Issued', value: stats.issued, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Bank Letter Amount</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/shipping-agent-license/${id}`)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const SA_ICON = IconShip;
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconShip,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_SA_APPLICATIONS, STATUS_COLOR } from './ShippingAgentLicenseQueuePage';
|
||||
import type { ShippingAgentApplication, LicenseStatus } from './ShippingAgentLicenseQueuePage';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeExpiryDate(approvalDate: string): string {
|
||||
const d = new Date(approvalDate);
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const DOCS = [
|
||||
{ key: 'bankLetter', label: 'Bank Letter (≥ 1.2M ETB)', fileName: 'bank_letter.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'shippingAgreement', label: 'Shipping Company Agreement', fileName: 'shipping_agreement.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'vehicleDoc', label: 'Vehicle Libre / Rental Agreement', fileName: 'vehicle_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'officeDoc', label: 'Office Title Deed / Rental Agreement', fileName: 'office_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'terminalDoc', label: 'Terminal Agreement / Title Deed', fileName: 'terminal_doc.pdf', icon: IconId, required: true },
|
||||
{ key: 'bookingClerkDocs', label: 'Booking Clerk Profile & Work Experience Evidence', fileName: 'booking_clerk_docs.pdf', icon: IconShieldCheck, required: true },
|
||||
{ key: 'canvasserDocs', label: 'Canvasser Profile & Work Experience Evidence', fileName: 'canvasser_docs.pdf', icon: IconShieldCheck, required: true },
|
||||
{ key: 'adminDocs', label: 'Administrative Staff Profile & Work Experience Evidence', fileName: 'admin_docs.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'ceoDocs', label: 'CEO/General Manager Profile & Work Experience Evidence', fileName: 'ceo_docs.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'passportPhoto', label: 'Passport-Size Photo', fileName: 'passport_photo.jpg', icon: IconCamera, required: true },
|
||||
];
|
||||
|
||||
export function ShippingAgentLicenseReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<ShippingAgentApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [app, setApp] = useState<ShippingAgentApplication | null>(null);
|
||||
const [status, setStatus] = useState<LicenseStatus>('Submitted');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/logistics-licenses/shipping-agent/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_SA_APPLICATIONS.find((a) => a.id === id) ?? null;
|
||||
setApp(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Rejected' || status === 'Certificate Issued';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !app) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as LicenseStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
|
||||
setApp((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks,
|
||||
expiryDate: newStatus === 'Certificate Issued' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
|
||||
} : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Application status updated to ${newStatus}.`);
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Application not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/shipping-agent-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/shipping-agent-license')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShip size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{app.companyName}</Title>
|
||||
<Text fz="sm" c="dimmed">{app.id} · Shipping Agent License</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Certificate Issued">
|
||||
Certificate issued on {app.approvalDate}. Valid until {app.expiryDate}.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
|
||||
This application has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">Take Action</Text>
|
||||
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
|
||||
Update Status
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Company Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Company Name" value={app.companyName} />
|
||||
<InfoRow label="TIN Number" value={app.tinNumber} />
|
||||
<InfoRow label="Commercial Reg. No." value={app.commercialRegNumber} />
|
||||
<InfoRow label="Business Address" value={app.businessAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Bank Letter / Capital Evidence</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Bank Name" value={app.bankName} />
|
||||
<InfoRow label="Capital Amount" value={`${app.capitalAmount.toLocaleString()} ETB`} />
|
||||
<InfoRow label="Minimum Required" value="1,200,000 ETB" />
|
||||
<InfoRow label="Meets Threshold" value={app.capitalAmount >= 1200000 ? 'Yes' : 'No'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
const ok = app.docsComplete;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
|
||||
<DocIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="xs">
|
||||
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
{ok ? (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={app.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
|
||||
<InfoRow label="Expiry Date (1 year)" value={app.expiryDate ?? 'Not yet set'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{status === 'Certificate Issued' && (
|
||||
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
|
||||
<IconCertificate size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="md" c="teal.7">Issued Certificate — Shipping Agent License</Text>
|
||||
<Text fz="xs" c="dimmed">Issued on {app.approvalDate} · Valid until {app.expiryDate} (1 year)</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconCertificate size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Shipping Agent License Certificate</Text>
|
||||
<Text fz="xs" c="dimmed">Includes QR code, certificate number, and applicant photo</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
|
||||
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Payment Pending', 'Payment Confirmed', 'Certificate Issued']}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the applicant..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={selectedStatus === 'Approved' || selectedStatus === 'Certificate Issued' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +1,21 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowRight,
|
||||
IconChartBar,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
IconFileDescription,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { MOCK_VESSEL_REGISTRATIONS } from '../../vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconAnchor;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<ThemeIcon size={46} radius="md" variant="light" color={color}>
|
||||
<Icon size={22} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
<Text fz={30} fw={800} mt="md" lh={1.1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{label}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationHeadDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const regs = MOCK_VESSEL_REGISTRATIONS;
|
||||
|
||||
const stats = {
|
||||
total: regs.length,
|
||||
underReview: regs.filter((r) => r.status === 'Under Review' || r.status === 'Pending').length,
|
||||
approved: regs.filter((r) => r.status === 'Approved').length,
|
||||
renewalsDue: regs.filter((r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue').length,
|
||||
};
|
||||
|
||||
const recent = [...regs]
|
||||
.sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1))
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconAnchor size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Title order={2}>Vessel Registration Department</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Overview of vessel registration and ownership transfer activity
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconChartBar size={16} />} onClick={() => navigate('/vessel-registration-report')}>
|
||||
Full Report
|
||||
</Button>
|
||||
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Open Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<StatCard label="Total Registrations" value={stats.total} icon={IconAnchor} color="blue" />
|
||||
<StatCard label="Awaiting Review" value={stats.underReview} icon={IconClockHour4} color="yellow" />
|
||||
<StatCard label="Approved" value={stats.approved} icon={IconCircleCheck} color="teal" />
|
||||
<StatCard label="Renewals Due / Overdue" value={stats.renewalsDue} icon={IconFileDescription} color="orange" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg" align="stretch">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} fz="lg">Recent Applications</Text>
|
||||
<Anchor size="sm" fw={600} onClick={() => navigate('/vessel-registration-queue')} style={{ cursor: 'pointer' }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
View queue
|
||||
<IconArrowRight size={14} />
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
{recent.map((r, i) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom: i < recent.length - 1 ? '1px solid var(--mantine-color-gray-2)' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => navigate(`/vessel-registration-queue/${r.id}`)}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{r.vesselName}</Text>
|
||||
<Text size="xs" c="dimmed">{r.id} — {r.ownerName}</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={STATUS_COLOR[r.status] ?? 'gray'} radius="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||
<Text fw={700} fz="lg" mb="md">Quick Links</Text>
|
||||
<Stack gap="sm">
|
||||
<Button fullWidth variant="light" leftSection={<IconAnchor size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Registration Queue
|
||||
</Button>
|
||||
<Button fullWidth variant="light" leftSection={<IconTransferIn size={16} />} justify="flex-start" onClick={() => navigate('/vessel-ownership-transfer')}>
|
||||
Ownership Transfer Queue
|
||||
</Button>
|
||||
<Button fullWidth variant="light" leftSection={<IconChartBar size={16} />} justify="flex-start" onClick={() => navigate('/vessel-registration-report')}>
|
||||
Registration Report
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration overview"
|
||||
description="Vessel registration is not connected to the backend yet, so there are no figures to report."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationHeadDashboardPage;
|
||||
|
||||
@@ -1,415 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconTransferIn,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data (self-contained — portal page has its own copy)
|
||||
// ---------------------------------------------------------------------------
|
||||
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
|
||||
|
||||
export interface OwnershipTransferRequest {
|
||||
id: string;
|
||||
vesselId: string;
|
||||
vesselName: string;
|
||||
category: string;
|
||||
vesselType: string;
|
||||
currentOwnerName: string;
|
||||
currentOwnerIdOrTin: string;
|
||||
currentOwnerPhone: string;
|
||||
newOwnerName: string;
|
||||
newOwnerIdOrTin: string;
|
||||
newOwnerPhone: string;
|
||||
newOwnerEmail: string;
|
||||
newOwnerAddress: string;
|
||||
transferReason: string;
|
||||
remarks: string;
|
||||
status: TransferStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
}
|
||||
|
||||
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
|
||||
{
|
||||
id: 'OT-2024-001',
|
||||
vesselId: 'VR-2024-001',
|
||||
vesselName: 'Lake Tana Star',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Passenger Ferry',
|
||||
currentOwnerName: 'Abebe Girma',
|
||||
currentOwnerIdOrTin: 'ET-9812345',
|
||||
currentOwnerPhone: '+251 911 234 567',
|
||||
newOwnerName: 'Tigist Haile',
|
||||
newOwnerIdOrTin: 'ET-7743210',
|
||||
newOwnerPhone: '+251 922 876 543',
|
||||
newOwnerEmail: 'tigist.haile@email.com',
|
||||
newOwnerAddress: 'Bahir Dar, Amhara Region',
|
||||
transferReason: 'Sale / Purchase',
|
||||
remarks: 'Vessel sold to new owner. Bill of sale attached.',
|
||||
status: 'Pending',
|
||||
submittedDate: '2024-06-01',
|
||||
approvalDate: null,
|
||||
},
|
||||
{
|
||||
id: 'OT-2024-002',
|
||||
vesselId: 'VR-2024-002',
|
||||
vesselName: 'Red Sea Voyager',
|
||||
category: 'Sea-going Vessel (International)',
|
||||
vesselType: 'General Cargo',
|
||||
currentOwnerName: 'Ethio Shipping Lines PLC',
|
||||
currentOwnerIdOrTin: 'TIN-0045678',
|
||||
currentOwnerPhone: '+251 115 501 010',
|
||||
newOwnerName: 'Ethiopian Maritime Transport S.C.',
|
||||
newOwnerIdOrTin: 'TIN-0078910',
|
||||
newOwnerPhone: '+251 115 502 020',
|
||||
newOwnerEmail: 'info@emtsc.et',
|
||||
newOwnerAddress: 'Addis Ababa, Bole Sub-city',
|
||||
transferReason: 'Corporate Restructuring',
|
||||
remarks: 'Merger-related transfer. Court order attached.',
|
||||
status: 'Under Review',
|
||||
submittedDate: '2024-05-20',
|
||||
approvalDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Under Review', label: 'Under Review' },
|
||||
{ value: 'Approved', label: 'Approved' },
|
||||
{ value: 'Rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
|
||||
|
||||
// Certificates generated on approval
|
||||
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
|
||||
const SEAGOING_CERTS = [
|
||||
'Certificate of Nationality',
|
||||
'Certificate of Ownership',
|
||||
'Certificate of Registration',
|
||||
'Minimum Safe Manning Certificate',
|
||||
];
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<OwnershipTransferRequest[]>([]);
|
||||
const [filtered, setFiltered] = useState<OwnershipTransferRequest[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<OwnershipTransferRequest | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState<string>('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest[]>();
|
||||
const [actionTrigger] = useApiMutation<{ success: boolean }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-ownership-transfers', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setRecords(Array.isArray(data) ? data : [data]))
|
||||
.catch(() => setRecords(MOCK_TRANSFER_REQUESTS));
|
||||
}, [fetchTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
let result = records;
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((r) =>
|
||||
r.vesselName.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q) ||
|
||||
r.currentOwnerName.toLowerCase().includes(q) ||
|
||||
r.newOwnerName.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (statusFilter) result = result.filter((r) => r.status === statusFilter);
|
||||
setFiltered(result);
|
||||
}, [records, search, statusFilter]);
|
||||
|
||||
const openDrawer = (req: OwnershipTransferRequest) => { setSelected(req); setDrawerOpen(true); };
|
||||
const closeDrawer = () => { setDrawerOpen(false); setSelected(null); };
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (!selected) return;
|
||||
setNewStatus(selected.status);
|
||||
setRemarks(selected.remarks ?? '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
const isApproval = newStatus === 'Approved';
|
||||
try {
|
||||
await actionTrigger({
|
||||
url: `/vessel-ownership-transfers/${selected.id}/status`,
|
||||
method: 'PATCH',
|
||||
body: { status: newStatus, remarks, generateCertificates: isApproval },
|
||||
}).unwrap();
|
||||
} catch { /* mock mode */ }
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setRecords((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === selected.id
|
||||
? { ...r, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : r.approvalDate }
|
||||
: r
|
||||
)
|
||||
);
|
||||
setSelected((prev) => prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : null);
|
||||
setModalOpen(false);
|
||||
setSaving(false);
|
||||
|
||||
if (isApproval) {
|
||||
const certs = selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
|
||||
notify.success(`Ownership transfer approved. ${certs.length} certificate(s) generated for ${selected.newOwnerName}.`);
|
||||
} else {
|
||||
notify.success('Status updated.');
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
const total = records.length;
|
||||
const pending = records.filter((r) => r.status === 'Pending').length;
|
||||
const underReview = records.filter((r) => r.status === 'Under Review').length;
|
||||
const approved = records.filter((r) => r.status === 'Approved').length;
|
||||
|
||||
const isTerminal = selected?.status === 'Approved' || selected?.status === 'Rejected';
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="violet" variant="light">
|
||||
<IconTransferIn size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Ownership Transfer Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process vessel ownership transfer requests</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'Total Requests', value: total, color: 'blue' },
|
||||
{ label: 'Pending', value: pending, color: 'gray' },
|
||||
{ label: 'Under Review', value: underReview, color: 'yellow' },
|
||||
{ label: 'Approved', value: approved, color: 'teal' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xl" fw={800} c={`${s.color}.6`}>{s.value}</Text>
|
||||
<Text fz="xs" c="dimmed">{s.label}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search vessel, owner..."
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All Statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v ?? '')}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md" style={{ overflow: 'hidden' }}>
|
||||
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Request ID</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Reason</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={8}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No transfer requests found.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : filtered.map((req) => (
|
||||
<Table.Tr key={req.id}>
|
||||
<Table.Td><Text fz="sm" fw={600} c="blue.6">{req.id}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={600}>{req.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{req.category}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{req.currentOwnerName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{req.newOwnerName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{req.transferReason}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{req.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => openDrawer(req)}>Review</Button>
|
||||
<Button size="xs" variant="subtle" onClick={() => navigate(`/vessel-ownership-transfer/${req.id}`)}>
|
||||
Details
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
{/* Drawer */}
|
||||
<Drawer
|
||||
opened={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
position="right"
|
||||
size="md"
|
||||
title={<Text fw={700}>Transfer Request — {selected?.id}</Text>}
|
||||
>
|
||||
{selected && (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="sm" p="sm">
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={6}>Vessel</Text>
|
||||
<Text fw={600}>{selected.vesselName}</Text>
|
||||
<Text fz="sm" c="dimmed">{selected.category} · {selected.vesselType}</Text>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'From (Owner)', value: selected.currentOwnerName },
|
||||
{ label: 'From (ID/TIN)', value: selected.currentOwnerIdOrTin },
|
||||
{ label: 'To (New Owner)', value: selected.newOwnerName },
|
||||
{ label: 'To (ID/TIN)', value: selected.newOwnerIdOrTin },
|
||||
{ label: 'New Owner Phone', value: selected.newOwnerPhone },
|
||||
{ label: 'New Owner Email', value: selected.newOwnerEmail || '—' },
|
||||
{ label: 'New Owner Address', value: selected.newOwnerAddress || '—' },
|
||||
{ label: 'Transfer Reason', value: selected.transferReason },
|
||||
{ label: 'Submitted', value: selected.submittedDate },
|
||||
{ label: 'Current Status', value: selected.status },
|
||||
].map((row) => (
|
||||
<div key={row.label}>
|
||||
<Text fz="xs" c="dimmed">{row.label}</Text>
|
||||
<Text fz="sm" fw={500}>{row.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{selected.remarks && (
|
||||
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
|
||||
<Text fz="xs" c="dimmed" mb={2}>Remarks</Text>
|
||||
<Text fz="sm">{selected.remarks}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Certificates preview */}
|
||||
{selected.status === 'Approved' && (
|
||||
<Paper withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
|
||||
<Group gap="xs" mb={6}>
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" fw={700} c="teal.7">Certificates Generated for {selected.newOwnerName}</Text>
|
||||
</Group>
|
||||
{(selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS).map((cert) => (
|
||||
<Text key={cert} fz="xs" c="teal.7">• {cert}</Text>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Button color="violet" fullWidth onClick={handleOpenModal}>
|
||||
Update Status
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="subtle" fullWidth onClick={() => navigate(`/vessel-ownership-transfer/${selected.id}`)}>
|
||||
View Full Details
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Status modal */}
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
|
||||
value={newStatus}
|
||||
onChange={(v) => setNewStatus(v ?? '')}
|
||||
/>
|
||||
{newStatus === 'Approved' && (
|
||||
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-teal-light)">
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7">
|
||||
Approving will transfer ownership to <strong>{selected?.newOwnerName}</strong> and auto-generate{' '}
|
||||
{selected?.category === 'Inland Waterway Vessel' ? '1 certificate' : '4 certificates'}.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
<Textarea label="Remarks" placeholder="Add any remarks or notes..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'} loading={saving} disabled={!newStatus} onClick={handleAction}>
|
||||
{newStatus === 'Approved' ? 'Approve & Transfer' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer queue"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferQueuePage;
|
||||
|
||||
@@ -1,334 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconShieldCheck,
|
||||
IconTransferIn,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_TRANSFER_REQUESTS } from './VesselOwnershipTransferQueuePage';
|
||||
import type { OwnershipTransferRequest, TransferStatus } from './VesselOwnershipTransferQueuePage';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const;
|
||||
|
||||
const INLAND_CERTS = ['Inland Vessel Registration Certificate'];
|
||||
const SEAGOING_CERTS = [
|
||||
'Certificate of Nationality',
|
||||
'Certificate of Ownership',
|
||||
'Certificate of Registration',
|
||||
'Minimum Safe Manning Certificate',
|
||||
];
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value ?? '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [record, setRecord] = useState<OwnershipTransferRequest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<OwnershipTransferRequest>();
|
||||
const [actionTrigger] = useApiMutation<{ success: boolean }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/vessel-ownership-transfers/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setRecord(data); setLoading(false); })
|
||||
.catch(() => {
|
||||
setRecord(MOCK_TRANSFER_REQUESTS.find((r) => r.id === id) ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [fetchTrigger, id]);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (!record) return;
|
||||
setNewStatus(record.status);
|
||||
setRemarks(record.remarks ?? '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!record) return;
|
||||
setSaving(true);
|
||||
const isApproval = newStatus === 'Approved';
|
||||
try {
|
||||
await actionTrigger({
|
||||
url: `/vessel-ownership-transfers/${record.id}/status`,
|
||||
method: 'PATCH',
|
||||
body: { status: newStatus, remarks, generateCertificates: isApproval },
|
||||
}).unwrap();
|
||||
} catch { /* mock */ }
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setRecord((prev) =>
|
||||
prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : prev
|
||||
);
|
||||
setModalOpen(false);
|
||||
setSaving(false);
|
||||
|
||||
if (isApproval) {
|
||||
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
|
||||
notify.success(`Transfer approved. ${certs.length} certificate(s) generated for ${record.newOwnerName}.`);
|
||||
} else {
|
||||
notify.success('Status updated.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Text fz="sm" c="dimmed" p="xl">Loading...</Text>;
|
||||
if (!record) return (
|
||||
<Stack p="xl" align="center">
|
||||
<Text c="dimmed">Transfer request not found.</Text>
|
||||
<Button variant="subtle" onClick={() => navigate('/vessel-ownership-transfer')}>Back to Queue</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const isTerminal = record.status === 'Approved' || record.status === 'Rejected';
|
||||
const certs = record.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="violet" variant="light">
|
||||
<IconTransferIn size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Ownership Transfer Review</Title>
|
||||
<Text fz="sm" c="dimmed">{record.id} · {record.vesselName}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge size="lg" color={STATUS_COLOR[record.status] ?? 'gray'} variant="light">{record.status}</Badge>
|
||||
<Button leftSection={<IconArrowLeft size={15} />} variant="default" size="sm" onClick={() => navigate('/vessel-ownership-transfer')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" style={{ alignItems: 'start' }}>
|
||||
{/* Left column */}
|
||||
<Stack gap="md">
|
||||
{/* Current owner */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Current Owner</Text>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<InfoRow label="Full Name" value={record.currentOwnerName} />
|
||||
<InfoRow label="National ID / TIN" value={record.currentOwnerIdOrTin} />
|
||||
<InfoRow label="Phone" value={record.currentOwnerPhone} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* New owner */}
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-violet-3)' }}>
|
||||
<Text fw={700} fz="sm" mb="sm" c="violet.7">New Owner</Text>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<InfoRow label="Full Name" value={record.newOwnerName} />
|
||||
<InfoRow label="National ID / TIN" value={record.newOwnerIdOrTin} />
|
||||
<InfoRow label="Phone" value={record.newOwnerPhone} />
|
||||
<InfoRow label="Email" value={record.newOwnerEmail} />
|
||||
<InfoRow label="Address" value={record.newOwnerAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Transfer info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Transfer Details</Text>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<InfoRow label="Vessel Name" value={record.vesselName} />
|
||||
<InfoRow label="Vessel Category" value={record.category} />
|
||||
<InfoRow label="Vessel Type" value={record.vesselType} />
|
||||
<InfoRow label="Transfer Reason" value={record.transferReason} />
|
||||
<InfoRow label="Submitted Date" value={record.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={record.approvalDate ?? 'Not yet approved'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* Right column */}
|
||||
<Stack gap="md">
|
||||
{/* Document */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Supporting Documents</Text>
|
||||
<Divider mb="sm" />
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={34} radius="sm" color="violet" variant="light">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>Bill of Sale / Transfer Document</Text>
|
||||
<Text fz="xs" c="dimmed">Legal transfer document</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" variant="light" color="blue">View</Button>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconDownload size={13} />}>Download</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
</Paper>
|
||||
|
||||
{/* Remarks */}
|
||||
{record.remarks && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks / Notes</Text>
|
||||
<Divider mb="sm" />
|
||||
<Text fz="sm">{record.remarks}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Timeline</Text>
|
||||
<Divider mb="sm" />
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" color="blue" variant="light">
|
||||
<IconCircleCheck size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">Submitted — {record.submittedDate}</Text>
|
||||
</Group>
|
||||
{record.status !== 'Pending' && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" color="yellow" variant="light">
|
||||
<IconCircleCheck size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">Under Review</Text>
|
||||
</Group>
|
||||
)}
|
||||
{record.status === 'Approved' && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" color="teal" variant="light">
|
||||
<IconCircleCheck size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">Approved — {record.approvalDate}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{record.status === 'Rejected' && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light">
|
||||
<IconCircleCheck size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">Rejected</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Certificates — shown after approval */}
|
||||
{record.status === 'Approved' && (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: 'var(--mantine-color-teal-5)' }}>
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconShieldCheck size={18} color="var(--mantine-color-teal-6)" />
|
||||
<Text fw={700} fz="sm" c="teal.7">Certificates Generated for {record.newOwnerName}</Text>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: certs.length }} spacing="sm">
|
||||
{certs.map((cert, i) => (
|
||||
<Card key={cert} withBorder radius="sm" p="sm" style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
|
||||
<Group gap="sm" mb="xs">
|
||||
<ThemeIcon size={28} radius="md" color="teal" variant="light">
|
||||
<Text fz="xs" fw={800}>{i + 1}</Text>
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" fw={600} style={{ flex: 1 }}>{cert}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="xs">Issued to: {record.newOwnerName} · {record.approvalDate}</Text>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={13} />} fullWidth>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fz="sm" c="dimmed">Current status: <strong>{record.status}</strong></Text>
|
||||
<Button color="violet" onClick={handleOpenModal}>Update Status</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Status modal */}
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Transfer Status" size="sm">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
data={ACTION_STATUSES.map((s) => ({ value: s, label: s }))}
|
||||
value={newStatus}
|
||||
onChange={(v) => setNewStatus(v ?? '')}
|
||||
/>
|
||||
{newStatus === 'Approved' && (
|
||||
<Alert icon={<IconCircleCheck size={14} />} color="teal" variant="light">
|
||||
Approving will officially transfer ownership to <strong>{record.newOwnerName}</strong> and generate{' '}
|
||||
{certs.length} certificate{certs.length > 1 ? 's' : ''}.
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea label="Remarks" placeholder="Add remarks..." value={remarks} onChange={(e) => setRemarks(e.currentTarget.value)} rows={3} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={newStatus === 'Approved' ? 'teal' : newStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={saving}
|
||||
disabled={!newStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
{newStatus === 'Approved' ? 'Approve & Transfer Ownership' : newStatus === 'Rejected' ? 'Reject' : 'Update Status'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer review"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferReviewPage;
|
||||
|
||||
@@ -1,535 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
IconClockHour4,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
|
||||
export type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
|
||||
export type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
|
||||
|
||||
export interface VesselRegistration {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
category: VesselCategory;
|
||||
vesselType: string;
|
||||
flagState: string;
|
||||
portOfRegistry: string;
|
||||
capacityLabel: 'Passenger Capacity' | 'Gross Tonnage (GT)';
|
||||
capacityValue: number;
|
||||
vesselLengthM: number;
|
||||
imoOrHullNumber: string;
|
||||
manufacturerShipyard: string;
|
||||
yearBuilt: number;
|
||||
engineType: string;
|
||||
enginePowerKw: number;
|
||||
numberOfEngines: number;
|
||||
hullMaterial: string;
|
||||
ownerName: string;
|
||||
ownerNationalIdOrTin: string;
|
||||
ownerPhone: string;
|
||||
ownerAddress: string;
|
||||
status: VesselRegStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
remarks: string;
|
||||
renewalStatus: RenewalStatus;
|
||||
expiryDate: string | null;
|
||||
docsComplete: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_VESSEL_REGISTRATIONS: VesselRegistration[] = [
|
||||
{
|
||||
id: 'VR-2024-001',
|
||||
vesselName: 'Lake Tana Star',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Passenger Ferry',
|
||||
flagState: 'Ethiopia',
|
||||
portOfRegistry: 'Bahir Dar',
|
||||
capacityLabel: 'Passenger Capacity',
|
||||
capacityValue: 120,
|
||||
vesselLengthM: 32,
|
||||
imoOrHullNumber: 'ETH-INL-2024-0042',
|
||||
manufacturerShipyard: 'Ethio Marine Works',
|
||||
yearBuilt: 2019,
|
||||
engineType: 'Diesel Engine',
|
||||
enginePowerKw: 450,
|
||||
numberOfEngines: 2,
|
||||
hullMaterial: 'Steel',
|
||||
ownerName: 'Abebe Girma',
|
||||
ownerNationalIdOrTin: 'ET-9812345',
|
||||
ownerPhone: '+251 911 234 567',
|
||||
ownerAddress: 'Bahir Dar, Amhara Region',
|
||||
status: 'Under Review',
|
||||
submittedDate: '2024-03-15',
|
||||
approvalDate: null,
|
||||
remarks: 'Documents submitted. Under initial review by maritime officer.',
|
||||
renewalStatus: 'Not Applicable',
|
||||
expiryDate: null,
|
||||
docsComplete: true,
|
||||
},
|
||||
{
|
||||
id: 'VR-2024-002',
|
||||
vesselName: 'Red Sea Voyager',
|
||||
category: 'Sea-going Vessel (International)',
|
||||
vesselType: 'General Cargo',
|
||||
flagState: 'Ethiopia',
|
||||
portOfRegistry: 'Djibouti (Nominated)',
|
||||
capacityLabel: 'Gross Tonnage (GT)',
|
||||
capacityValue: 4200,
|
||||
vesselLengthM: 98,
|
||||
imoOrHullNumber: 'IMO9876543',
|
||||
manufacturerShipyard: 'Hyundai Heavy Industries',
|
||||
yearBuilt: 2015,
|
||||
engineType: 'Diesel Engine',
|
||||
enginePowerKw: 8500,
|
||||
numberOfEngines: 1,
|
||||
hullMaterial: 'Steel',
|
||||
ownerName: 'Ethio Shipping Lines PLC',
|
||||
ownerNationalIdOrTin: 'TIN-0045678',
|
||||
ownerPhone: '+251 115 501 010',
|
||||
ownerAddress: 'Addis Ababa, Bole Sub-city',
|
||||
status: 'Pending',
|
||||
submittedDate: '2024-04-02',
|
||||
approvalDate: null,
|
||||
remarks: '',
|
||||
renewalStatus: 'Not Applicable',
|
||||
expiryDate: null,
|
||||
docsComplete: false,
|
||||
},
|
||||
{
|
||||
id: 'VR-2023-018',
|
||||
vesselName: 'Hawassa Queen',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Water Taxi',
|
||||
flagState: 'Ethiopia',
|
||||
portOfRegistry: 'Hawassa',
|
||||
capacityLabel: 'Passenger Capacity',
|
||||
capacityValue: 24,
|
||||
vesselLengthM: 12,
|
||||
imoOrHullNumber: 'ETH-INL-2023-0018',
|
||||
manufacturerShipyard: 'Ethio Marine Works',
|
||||
yearBuilt: 2022,
|
||||
engineType: 'Outboard Motor',
|
||||
enginePowerKw: 90,
|
||||
numberOfEngines: 2,
|
||||
hullMaterial: 'Aluminum',
|
||||
ownerName: 'Yohannes Desta',
|
||||
ownerNationalIdOrTin: 'ET-4456789',
|
||||
ownerPhone: '+251 933 112 234',
|
||||
ownerAddress: 'Hawassa, Sidama Region',
|
||||
status: 'Approved',
|
||||
submittedDate: '2023-11-10',
|
||||
approvalDate: '2023-12-05',
|
||||
remarks: 'All documents verified. Registration approved.',
|
||||
renewalStatus: 'Valid',
|
||||
expiryDate: '2028-12-05',
|
||||
docsComplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
function computeRenewalStatus(approvalDate: string | null): RenewalStatus {
|
||||
if (!approvalDate) return 'Not Applicable';
|
||||
const expiry = new Date(approvalDate);
|
||||
expiry.setFullYear(expiry.getFullYear() + 5);
|
||||
const days = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
if (days < 0) return 'Overdue';
|
||||
if (days <= 180) return 'Due Soon';
|
||||
return 'Valid';
|
||||
}
|
||||
|
||||
function RenewalBadge({ status }: { status: RenewalStatus }) {
|
||||
if (status === 'Due Soon') return <Badge color="orange" size="xs">Due Soon</Badge>;
|
||||
if (status === 'Overdue') return <Badge color="red" size="xs">Overdue</Badge>;
|
||||
if (status === 'Valid') return <Badge color="teal" size="xs">Valid</Badge>;
|
||||
return <Text fz="xs" c="dimmed">—</Text>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function VesselDrawer({
|
||||
reg,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
reg: VesselRegistration | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'correction' | null>(null);
|
||||
|
||||
if (!reg) return null;
|
||||
|
||||
const isTerminal = reg.status === 'Approved' || reg.status === 'Rejected';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'correction') => {
|
||||
onAction(reg.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Registration ${reg.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(reg.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
{/* Vessel info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Vessel Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Vessel Name', value: reg.vesselName },
|
||||
{ label: 'Category', value: reg.category },
|
||||
{ label: 'Type', value: reg.vesselType },
|
||||
{ label: reg.capacityLabel, value: String(reg.capacityValue) },
|
||||
{ label: 'Length (m)', value: String(reg.vesselLengthM) },
|
||||
{ label: 'IMO / Hull No.', value: reg.imoOrHullNumber },
|
||||
{ label: 'Owner', value: reg.ownerName },
|
||||
{ label: 'Submitted', value: reg.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Document checklist */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Vessel Photos', ok: reg.docsComplete },
|
||||
{ label: 'Proof of Ownership', ok: reg.docsComplete },
|
||||
{ label: "Builder's Certificate", ok: reg.docsComplete },
|
||||
{ label: 'Insurance Certificate', ok: reg.docsComplete },
|
||||
{ label: 'Tax Clearance Certificate', ok: reg.docsComplete },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Status */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light">{reg.status}</Badge>
|
||||
</Group>
|
||||
{reg.remarks && <Text fz="xs" c="dimmed">{reg.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{/* Actions */}
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!reg.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('correction')}>
|
||||
Request Correction
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Correction'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve registration ${reg.id} for vessel "${reg.vesselName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject registration ${reg.id}? This cannot be undone.`
|
||||
: `Request corrections for registration ${reg.id}?`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<VesselRegistration[]>(MOCK_VESSEL_REGISTRATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
const [selectedReg, setSelectedReg] = useState<VesselRegistration | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-registrations?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'correction', remarks: string) => {
|
||||
setApps((prev) => prev.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
const newStatus: VesselRegStatus =
|
||||
action === 'approve' ? 'Approved' : action === 'reject' ? 'Rejected' : 'Correction Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : r.approvalDate;
|
||||
return {
|
||||
...r,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks: remarks || r.remarks,
|
||||
renewalStatus: action === 'approve' ? computeRenewalStatus(approvalDate) : r.renewalStatus,
|
||||
expiryDate: action === 'approve' && approvalDate
|
||||
? (() => { const d = new Date(approvalDate); d.setFullYear(d.getFullYear() + 5); return d.toISOString().split('T')[0]; })()
|
||||
: r.expiryDate,
|
||||
};
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Registration approved.' :
|
||||
action === 'reject' ? 'Registration rejected.' :
|
||||
'Correction request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || r.vesselName.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || r.ownerName.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || r.status === statusFilter;
|
||||
const matchCat = !categoryFilter || r.category === categoryFilter;
|
||||
return matchSearch && matchStatus && matchCat;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
underReview: apps.filter((r) => r.status === 'Under Review').length,
|
||||
approved: apps.filter((r) => r.status === 'Approved').length,
|
||||
rejected: apps.filter((r) => r.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((reg) => (
|
||||
<Table.Tr key={reg.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>{reg.id}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{reg.vesselName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{reg.vesselType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'}>
|
||||
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{reg.ownerName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" c="dimmed">{reg.submittedDate}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
|
||||
{reg.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RenewalBadge status={reg.renewalStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/vessel-registration-queue/${reg.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => { setSelectedReg(reg); setDrawerOpen(true); }}
|
||||
>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process vessel registration applications</Text>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'Under Review', value: stats.underReview, color: 'yellow' },
|
||||
{ label: 'Approved', value: stats.approved, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by vessel name, ID, or owner..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Pending', 'Under Review', 'Approved', 'Rejected', 'Correction Required']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All categories"
|
||||
clearable
|
||||
data={['Inland Waterway Vessel', 'Sea-going Vessel (International)']}
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reg ID</Table.Th>
|
||||
<Table.Th>Vessel Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Renewal</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No registrations found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<VesselDrawer
|
||||
reg={selectedReg}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/vessel-registration-queue/${id}`)}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration queue"
|
||||
description="Vessel registration is not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationQueuePage;
|
||||
|
||||
@@ -1,235 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconAlertCircle,
|
||||
IconCircleCheck,
|
||||
IconShip,
|
||||
IconWaveSine,
|
||||
} from '@tabler/icons-react';
|
||||
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
|
||||
import type { VesselRegistration } from './VesselRegistrationQueuePage';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationReportPage() {
|
||||
const data = MOCK_VESSEL_REGISTRATIONS;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = data.length;
|
||||
const inland = data.filter((r) => r.category === 'Inland Waterway Vessel').length;
|
||||
const seagoing = data.filter((r) => r.category === 'Sea-going Vessel (International)').length;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const approvedThisYear = data.filter(
|
||||
(r) => r.status === 'Approved' && r.approvalDate?.startsWith(String(currentYear))
|
||||
).length;
|
||||
|
||||
const statusCounts: Record<string, number> = {};
|
||||
data.forEach((r) => { statusCounts[r.status] = (statusCounts[r.status] ?? 0) + 1; });
|
||||
const statusDist = Object.entries(statusCounts).map(([label, count]) => ({
|
||||
label,
|
||||
count,
|
||||
pct: total > 0 ? Math.round((count / total) * 100) : 0,
|
||||
color: STATUS_COLOR[label] ?? 'gray',
|
||||
}));
|
||||
|
||||
const typeCounts: Record<string, number> = {};
|
||||
data.forEach((r) => { typeCounts[r.vesselType] = (typeCounts[r.vesselType] ?? 0) + 1; });
|
||||
const typeDist = Object.entries(typeCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([label, count]) => ({
|
||||
label,
|
||||
count,
|
||||
pct: total > 0 ? Math.round((count / total) * 100) : 0,
|
||||
}));
|
||||
|
||||
const renewalDue = data.filter(
|
||||
(r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue'
|
||||
);
|
||||
|
||||
const recent = [...data]
|
||||
.sort((a, b) => new Date(b.submittedDate).getTime() - new Date(a.submittedDate).getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
return { total, inland, seagoing, approvedThisYear, statusDist, typeDist, renewalDue, recent };
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Vessel Registration Report</Title>
|
||||
<Text fz="sm" c="dimmed">Summary of all vessel registrations and renewal status</Text>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Registered', value: stats.total, color: 'blue', icon: IconAnchor },
|
||||
{ label: 'Inland Vessels', value: stats.inland, color: 'cyan', icon: IconWaveSine },
|
||||
{ label: 'Sea-going Vessels', value: stats.seagoing, color: 'indigo', icon: IconShip },
|
||||
{ label: 'Approved This Year', value: stats.approvedThisYear, color: 'teal', icon: IconCircleCheck },
|
||||
].map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Group gap="sm" mb={4}>
|
||||
<ThemeIcon size={32} radius="md" color={s.color} variant="light">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
</Group>
|
||||
<Text fz="2xl" fw={700} c={`${s.color}.6`}>{s.value}</Text>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{/* Status Distribution */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="md">Status Distribution</Text>
|
||||
<Stack gap="sm">
|
||||
{stats.statusDist.map((s) => (
|
||||
<div key={s.label}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap="xs">
|
||||
<Badge color={s.color} size="xs" variant="light">{s.label}</Badge>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed">{s.count} ({s.pct}%)</Text>
|
||||
</Group>
|
||||
<Progress value={s.pct} color={s.color} size="sm" radius="xl" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Vessel Type Breakdown */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="md">Vessel Type Breakdown</Text>
|
||||
<Stack gap="sm">
|
||||
{stats.typeDist.map((t) => (
|
||||
<div key={t.label}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz="sm">{t.label}</Text>
|
||||
<Text fz="xs" c="dimmed">{t.count} ({t.pct}%)</Text>
|
||||
</Group>
|
||||
<Progress value={t.pct} color="blue" size="sm" radius="xl" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Renewal Tracking */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconAlertCircle size={17} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={700} fz="sm">Renewal Tracking</Text>
|
||||
{stats.renewalDue.length > 0 && (
|
||||
<Badge color="orange" size="sm">{stats.renewalDue.length} due</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{stats.renewalDue.length === 0 ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="sm" c="dimmed">No vessel registrations due for renewal in the next 180 days.</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reg ID</Table.Th>
|
||||
<Table.Th>Vessel Name</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Expiry Date</Table.Th>
|
||||
<Table.Th>Renewal Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{stats.renewalDue.map((reg) => (
|
||||
<Table.Tr key={reg.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
|
||||
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{reg.expiryDate ?? '—'}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={reg.renewalStatus === 'Overdue' ? 'red' : 'orange'} size="sm">
|
||||
{reg.renewalStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Recent Registrations */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Recent Registrations (last {stats.recent.length})</Text>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reg ID</Table.Th>
|
||||
<Table.Th>Vessel Name</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{stats.recent.map((reg) => (
|
||||
<Table.Tr key={reg.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{reg.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{reg.vesselName}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" color={reg.category === 'Inland Waterway Vessel' ? 'blue' : 'indigo'} variant="light">
|
||||
{reg.category === 'Inland Waterway Vessel' ? 'Inland' : 'Sea-going'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{reg.vesselType}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{reg.ownerName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{reg.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[reg.status] ?? 'gray'} variant="light" size="sm">
|
||||
{reg.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration report"
|
||||
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationReportPage;
|
||||
|
||||
@@ -1,393 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCertificate,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconShieldCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_VESSEL_REGISTRATIONS } from './VesselRegistrationQueuePage';
|
||||
import type { VesselRegistration, VesselRegStatus } from './VesselRegistrationQueuePage';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Certificate definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
const INLAND_CERTIFICATES = [
|
||||
{ label: 'Inland Vessel Registration Certificate', description: 'Official government registration document for inland waterway operation' },
|
||||
];
|
||||
|
||||
const SEAGOING_CERTIFICATES = [
|
||||
{ label: 'Certificate of Nationality', description: "Certifies the vessel's nationality and right to fly the Ethiopian flag" },
|
||||
{ label: 'Certificate of Ownership', description: 'Confirms legal ownership of the vessel' },
|
||||
{ label: 'Certificate of Registration', description: 'Official registration document for international sea-going operation' },
|
||||
{ label: 'Minimum Safe Manning Certificate', description: 'Specifies the minimum crew required for safe operation of the vessel' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
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() + 5);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
'Correction Required': 'orange',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselRegistrationReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [reg, setReg] = useState<VesselRegistration | null>(null);
|
||||
const [status, setStatus] = useState<VesselRegStatus>('Pending');
|
||||
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: `/vessel-registrations/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setReg(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_VESSEL_REGISTRATIONS.find((r) => r.id === id) ?? null;
|
||||
setReg(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Approved' || status === 'Rejected';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !reg) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as VesselRegStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : reg.approvalDate;
|
||||
setReg((prev) => prev ? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
approvalDate,
|
||||
remarks,
|
||||
expiryDate: newStatus === 'Approved' && approvalDate ? computeExpiryDate(approvalDate) : prev.expiryDate,
|
||||
} : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Registration ${newStatus.toLowerCase()}.`);
|
||||
};
|
||||
|
||||
if (!reg) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Registration not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={180} onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const certs = reg.category === 'Sea-going Vessel (International)' ? SEAGOING_CERTIFICATES : INLAND_CERTIFICATES;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registration-queue')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Group>
|
||||
<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">
|
||||
<IconAnchor size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{reg.vesselName}</Title>
|
||||
<Text fz="sm" c="dimmed">{reg.id} · {reg.category}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Terminal alerts */}
|
||||
{status === 'Approved' && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Registration Approved">
|
||||
This vessel registration was approved on {reg.approvalDate}. Expiry date: {reg.expiryDate}.
|
||||
{reg.category === 'Sea-going Vessel (International)'
|
||||
? ' Four certificates have been issued (Nationality, Ownership, Registration, Minimum Safe Manning).'
|
||||
: ' Inland Vessel Registration Certificate has been issued.'}
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Registration Rejected">
|
||||
This registration has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
{!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">
|
||||
{/* Left column */}
|
||||
<Stack gap="md">
|
||||
{/* Vessel Information */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Vessel Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Vessel Name" value={reg.vesselName} />
|
||||
<InfoRow label="Category" value={reg.category} />
|
||||
<InfoRow label="Vessel Type" value={reg.vesselType} />
|
||||
<InfoRow label={reg.capacityLabel} value={String(reg.capacityValue)} />
|
||||
<InfoRow label="Length (m)" value={String(reg.vesselLengthM)} />
|
||||
<InfoRow label="Flag State" value={reg.flagState} />
|
||||
<InfoRow label="Port of Registry" value={reg.portOfRegistry} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Technical Details */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Technical Details</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="IMO / Hull Number" value={reg.imoOrHullNumber} />
|
||||
<InfoRow label="Manufacturer / Shipyard" value={reg.manufacturerShipyard} />
|
||||
<InfoRow label="Year Built" value={String(reg.yearBuilt)} />
|
||||
<InfoRow label="Engine Type" value={reg.engineType} />
|
||||
<InfoRow label="Engine Power (kW)" value={String(reg.enginePowerKw)} />
|
||||
<InfoRow label="Number of Engines" value={String(reg.numberOfEngines)} />
|
||||
<InfoRow label="Hull Material" value={reg.hullMaterial} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Ownership */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Ownership</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Owner Name" value={reg.ownerName} />
|
||||
<InfoRow label="National ID / TIN" value={reg.ownerNationalIdOrTin} />
|
||||
<InfoRow label="Phone" value={reg.ownerPhone} />
|
||||
<InfoRow label="Address" value={reg.ownerAddress} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* Right column */}
|
||||
<Stack gap="md">
|
||||
{/* Uploaded Documents */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{(reg.category === 'Inland Waterway Vessel'
|
||||
? [
|
||||
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
|
||||
]
|
||||
: [
|
||||
{ key: 'vesselPhotos', label: 'Vessel Photos', fileName: 'vessel_photos.jpg', icon: IconCamera, ok: reg.docsComplete, required: true },
|
||||
{ key: 'proofOfOwnership', label: 'Proof of Ownership / Bill of Sale', fileName: 'proof_of_ownership.pdf', icon: IconFileDescription, ok: reg.docsComplete, required: true },
|
||||
{ key: 'shipParticulars', label: 'Ship Particulars', fileName: 'ship_particulars.pdf', icon: IconId, ok: reg.docsComplete, required: true },
|
||||
{ key: 'insuranceCertificate', label: 'Insurance Certificate', fileName: 'insurance_cert.pdf', icon: IconShieldCheck, ok: reg.docsComplete, required: true },
|
||||
]
|
||||
).map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
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={doc.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>
|
||||
{doc.ok ? (
|
||||
<Text fz="xs" c="dimmed">{doc.fileName}</Text>
|
||||
) : (
|
||||
<Text fz="xs" c="red">Not uploaded</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{doc.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>
|
||||
)}
|
||||
{!doc.ok && (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light">
|
||||
<IconX size={13} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Remarks */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={reg.remarks ? undefined : 'dimmed'}>{reg.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
{/* Submission info */}
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={reg.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={reg.approvalDate ?? 'Pending'} />
|
||||
<InfoRow label="Expiry Date (5 years)" value={reg.expiryDate ?? 'Not yet set'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Issued Certificates — full width, shown after approval ── */}
|
||||
{status === 'Approved' && (
|
||||
<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">
|
||||
{reg.category === 'Sea-going Vessel (International)'
|
||||
? 'Issued Certificates — Sea-going Vessel (4 Certificates)'
|
||||
: 'Issued Certificate — Inland Vessel'}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Approved on {reg.approvalDate} · Valid until {reg.expiryDate} (5 years)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{certs.map((cert, idx) => (
|
||||
<Card key={cert.label} 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">{cert.label}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color="teal" size="xs" variant="filled">#{idx + 1}</Badge>
|
||||
</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>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Status update modal */}
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Registration Status" size="md">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={['Under Review', 'Approved', 'Rejected', 'Correction Required']}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the vessel owner..."
|
||||
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' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel registration review"
|
||||
description="Vessel registration is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselRegistrationReviewPage;
|
||||
|
||||
@@ -1,452 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconEye,
|
||||
IconSearch,
|
||||
IconShieldOff,
|
||||
IconX,
|
||||
IconAlertCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
export type WaiverType = 'Pre-Waiver' | 'Post-Waiver';
|
||||
|
||||
export type WaiverStatus =
|
||||
| 'Submitted'
|
||||
| 'Under Review'
|
||||
| 'Under Evaluation'
|
||||
| 'Approved'
|
||||
| 'Resubmit Required'
|
||||
| 'Rejected'
|
||||
| 'Penalty Payment Pending'
|
||||
| 'Payment Confirmed'
|
||||
| 'Letter Generated'
|
||||
| 'Completed';
|
||||
|
||||
export interface WaiverApplication {
|
||||
id: string;
|
||||
companyName: string;
|
||||
tinNumber: string;
|
||||
importCertNumber: string;
|
||||
invoiceNumber: string;
|
||||
billOfLadingNumber: string;
|
||||
vesselName: string;
|
||||
portOfLoading: string;
|
||||
portOfDischarge: string;
|
||||
waiverType: WaiverType;
|
||||
status: WaiverStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
remarks: string;
|
||||
docsComplete: boolean;
|
||||
penaltyAmount: number | null;
|
||||
penaltyPaid: boolean;
|
||||
}
|
||||
|
||||
export const MOCK_WAIVER_APPLICATIONS: WaiverApplication[] = [
|
||||
{
|
||||
id: 'WVR-2024-001',
|
||||
companyName: 'Blue Nile Import & Trading PLC',
|
||||
tinNumber: 'TIN-0098231',
|
||||
importCertNumber: 'IC-772341',
|
||||
invoiceNumber: 'INV-55231',
|
||||
billOfLadingNumber: 'BL-90211',
|
||||
vesselName: 'MV Horizon Star',
|
||||
portOfLoading: 'Jebel Ali',
|
||||
portOfDischarge: 'Djibouti',
|
||||
waiverType: 'Pre-Waiver',
|
||||
status: 'Under Evaluation',
|
||||
submittedDate: '2024-03-14',
|
||||
approvalDate: null,
|
||||
remarks: 'Reviewing shipment schedule evidence.',
|
||||
docsComplete: true,
|
||||
penaltyAmount: null,
|
||||
penaltyPaid: false,
|
||||
},
|
||||
{
|
||||
id: 'WVR-2024-002',
|
||||
companyName: 'Red Sea Gateway Logistics Ltd',
|
||||
tinNumber: 'TIN-0071122',
|
||||
importCertNumber: 'IC-813457',
|
||||
invoiceNumber: 'INV-61120',
|
||||
billOfLadingNumber: 'BL-77102',
|
||||
vesselName: 'MV Amber Wave',
|
||||
portOfLoading: 'Salalah',
|
||||
portOfDischarge: 'Djibouti',
|
||||
waiverType: 'Post-Waiver',
|
||||
status: 'Penalty Payment Pending',
|
||||
submittedDate: '2024-04-05',
|
||||
approvalDate: '2024-04-10',
|
||||
remarks: 'Approved. Awaiting penalty payment.',
|
||||
docsComplete: true,
|
||||
penaltyAmount: 15000,
|
||||
penaltyPaid: false,
|
||||
},
|
||||
{
|
||||
id: 'WVR-2023-017',
|
||||
companyName: 'Tana Maritime & Trading PLC',
|
||||
tinNumber: 'TIN-0045690',
|
||||
importCertNumber: 'IC-704128',
|
||||
invoiceNumber: 'INV-40213',
|
||||
billOfLadingNumber: 'BL-33012',
|
||||
vesselName: 'MV Nile Pioneer',
|
||||
portOfLoading: 'Port Sudan',
|
||||
portOfDischarge: 'Djibouti',
|
||||
waiverType: 'Post-Waiver',
|
||||
status: 'Completed',
|
||||
submittedDate: '2023-10-12',
|
||||
approvalDate: '2023-11-08',
|
||||
remarks: 'Penalty paid. Letter generated and downloaded.',
|
||||
docsComplete: true,
|
||||
penaltyAmount: 12000,
|
||||
penaltyPaid: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
Draft: 'gray',
|
||||
Submitted: 'blue',
|
||||
'Under Review': 'yellow',
|
||||
'Under Evaluation': 'yellow',
|
||||
Approved: 'teal',
|
||||
'Resubmit Required': 'orange',
|
||||
Rejected: 'red',
|
||||
'Penalty Payment Pending': 'grape',
|
||||
'Payment Confirmed': 'indigo',
|
||||
'Letter Generated': 'green',
|
||||
Completed: 'green',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
function ApplicationDrawer({
|
||||
app,
|
||||
opened,
|
||||
onClose,
|
||||
onAction,
|
||||
onFullReview,
|
||||
}: {
|
||||
app: WaiverApplication | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onAction: (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => void;
|
||||
onFullReview: (id: string) => void;
|
||||
}) {
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [confirmModal, setConfirmModal] = useState<'approve' | 'reject' | 'resubmit' | null>(null);
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
const isTerminal = app.status === 'Rejected' || app.status === 'Completed';
|
||||
|
||||
const submit = (action: 'approve' | 'reject' | 'resubmit') => {
|
||||
onAction(app.id, action, remarks);
|
||||
setRemarks('');
|
||||
setConfirmModal(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer opened={opened} onClose={onClose} title={`Application ${app.id}`} position="right" size="lg" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Button variant="light" leftSection={<IconEye size={15} />} fullWidth onClick={() => { onClose(); onFullReview(app.id); }}>
|
||||
Open Full Review Page
|
||||
</Button>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Applicant Information</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'Company Name', value: app.companyName },
|
||||
{ label: 'TIN Number', value: app.tinNumber },
|
||||
{ label: 'Import Certificate No.', value: app.importCertNumber },
|
||||
{ label: 'Waiver Type', value: app.waiverType },
|
||||
{ label: 'Vessel Name', value: app.vesselName },
|
||||
{ label: 'Submitted', value: app.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Document Checklist</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
{ label: 'Import Certificate', ok: app.docsComplete },
|
||||
{ label: 'Invoice for Imported Products', ok: app.docsComplete },
|
||||
{ label: 'TIN Certificate', ok: app.docsComplete },
|
||||
{ label: 'Applicant Declaration', ok: app.docsComplete },
|
||||
...(app.waiverType === 'Post-Waiver'
|
||||
? [{ label: 'Bill of Lading & Arrival Notice', ok: app.docsComplete }]
|
||||
: []),
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={20} radius="xl" color={item.ok ? 'teal' : 'red'} variant={item.ok ? 'filled' : 'light'}>
|
||||
{item.ok ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} fz="sm">Current Status</Text>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light">{app.status}</Badge>
|
||||
</Group>
|
||||
{app.remarks && <Text fz="xs" c="dimmed">{app.remarks}</Text>}
|
||||
</Paper>
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={600} fz="sm" mb="sm">Officer Remarks</Text>
|
||||
<Textarea
|
||||
placeholder="Add notes or instructions..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={3}
|
||||
mb="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button size="xs" color="teal" leftSection={<IconCircleCheck size={14} />}
|
||||
disabled={!app.docsComplete}
|
||||
onClick={() => setConfirmModal('approve')}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="xs" color="orange" leftSection={<IconAlertCircle size={14} />}
|
||||
onClick={() => setConfirmModal('resubmit')}>
|
||||
Request Resubmission
|
||||
</Button>
|
||||
<Button size="xs" color="red" leftSection={<IconX size={14} />}
|
||||
onClick={() => setConfirmModal('reject')}>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
opened={!!confirmModal}
|
||||
onClose={() => setConfirmModal(null)}
|
||||
title={confirmModal === 'approve' ? 'Confirm Approval' : confirmModal === 'reject' ? 'Confirm Rejection' : 'Request Resubmission'}
|
||||
size="sm"
|
||||
>
|
||||
<Text fz="sm" mb="md">
|
||||
{confirmModal === 'approve'
|
||||
? `Approve application ${app.id} for "${app.companyName}"?`
|
||||
: confirmModal === 'reject'
|
||||
? `Reject application ${app.id}? This cannot be undone.`
|
||||
: `Request resubmission for application ${app.id}? Officer comment is required.`}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" size="sm" onClick={() => setConfirmModal(null)}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color={confirmModal === 'approve' ? 'teal' : confirmModal === 'reject' ? 'red' : 'orange'}
|
||||
disabled={confirmModal === 'resubmit' && !remarks.trim()}
|
||||
onClick={() => confirmModal && submit(confirmModal)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function WaiverQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const [apps, setApps] = useState<WaiverApplication[]>(MOCK_WAIVER_APPLICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [selectedApp, setSelectedApp] = useState<WaiverApplication | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<WaiverApplication[]>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/logistics-waivers?take=100', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setApps(data))
|
||||
.catch(() => {/* keep mock */});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const handleAction = (id: string, action: 'approve' | 'reject' | 'resubmit', remarks: string) => {
|
||||
setApps((prev) => prev.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const newStatus: WaiverStatus =
|
||||
action === 'approve'
|
||||
? (a.waiverType === 'Post-Waiver' ? 'Penalty Payment Pending' : 'Letter Generated')
|
||||
: action === 'reject' ? 'Rejected' : 'Resubmit Required';
|
||||
const approvalDate = action === 'approve' ? new Date().toISOString().split('T')[0] : a.approvalDate;
|
||||
return { ...a, status: newStatus, approvalDate, remarks: remarks || a.remarks };
|
||||
}));
|
||||
notify.success(
|
||||
action === 'approve' ? 'Application approved.' :
|
||||
action === 'reject' ? 'Application rejected.' :
|
||||
'Resubmission request sent.'
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = apps.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = !q || a.companyName.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || a.tinNumber.toLowerCase().includes(q);
|
||||
const matchStatus = !statusFilter || a.status === statusFilter;
|
||||
const matchType = !typeFilter || a.waiverType === typeFilter;
|
||||
return matchSearch && matchStatus && matchType;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: apps.length,
|
||||
inProgress: apps.filter((a) => a.status === 'Submitted' || a.status === 'Under Review' || a.status === 'Under Evaluation').length,
|
||||
letterGenerated: apps.filter((a) => a.status === 'Letter Generated' || a.status === 'Completed').length,
|
||||
rejected: apps.filter((a) => a.status === 'Rejected').length,
|
||||
};
|
||||
|
||||
const rows = filtered.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.companyName}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{app.tinNumber}</Text></Table.Td>
|
||||
<Table.Td><Badge variant="outline" size="sm" color={app.waiverType === 'Post-Waiver' ? 'grape' : 'blue'}>{app.waiverType}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" c="dimmed">{app.submittedDate}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[app.status] ?? 'gray'} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => navigate(`/waiver/${app.id}`)}>
|
||||
Review
|
||||
</Button>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={() => { setSelectedApp(app); setDrawerOpen(true); }}>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Maritime Logistics Waiver Queue</Title>
|
||||
<Text fz="sm" c="dimmed">Review and process Pre-Waiver and Post-Waiver applications</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
{[
|
||||
{ label: 'Total Applications', value: stats.total, color: 'blue' },
|
||||
{ label: 'In Progress', value: stats.inProgress, color: 'yellow' },
|
||||
{ label: 'Letters Generated', value: stats.letterGenerated, color: 'teal' },
|
||||
{ label: 'Rejected', value: stats.rejected, color: 'red' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} withBorder radius="md" p="md">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
|
||||
<Text fz="xl" fw={700} c={`${s.color}.6`} mt={4}>{s.value}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search by company name, ID, or TIN..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All types"
|
||||
clearable
|
||||
data={['Pre-Waiver', 'Post-Waiver']}
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
w={160}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={['Submitted', 'Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected', 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed']}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
w={220}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>App ID</Table.Th>
|
||||
<Table.Th>Company Name</Table.Th>
|
||||
<Table.Th>TIN Number</Table.Th>
|
||||
<Table.Th>Waiver Type</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="xl">No applications found</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
|
||||
<ApplicationDrawer
|
||||
app={selectedApp}
|
||||
opened={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onAction={handleAction}
|
||||
onFullReview={(id) => navigate(`/waiver/${id}`)}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Waiver queue"
|
||||
description="Waiver applications are not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export const WAIVER_ICON = IconShieldOff;
|
||||
export default WaiverQueuePage;
|
||||
|
||||
@@ -1,361 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconEye,
|
||||
IconFileCertificate,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconReceipt,
|
||||
IconShieldOff,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { MOCK_WAIVER_APPLICATIONS, STATUS_COLOR } from './WaiverQueuePage';
|
||||
import type { WaiverApplication, WaiverStatus } from './WaiverQueuePage';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600} lh={1.2}>{label}</Text>
|
||||
<Text fz="sm" mt={2}>{value || '—'}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DOCS_COMMON = [
|
||||
{ key: 'importCertificate', label: 'Import Certificate', fileName: 'import_certificate.pdf', icon: IconId, required: true },
|
||||
{ key: 'invoice', label: 'Invoice for Imported Products', fileName: 'invoice.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'tinCert', label: 'TIN Certificate', fileName: 'tin_certificate.pdf', icon: IconId, required: true },
|
||||
{ key: 'declaration', label: 'Applicant Declaration', fileName: 'declaration.pdf', icon: IconFileDescription, required: true },
|
||||
];
|
||||
|
||||
const DOCS_POST_WAIVER = [
|
||||
{ key: 'billOfLading', label: 'Bill of Lading', fileName: 'bill_of_lading.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'arrivalNotice', label: 'Arrival Notice / Port Arrival Evidence', fileName: 'arrival_notice.pdf', icon: IconFileDescription, required: true },
|
||||
{ key: 'penaltyReceipt', label: 'Penalty Payment Receipt', fileName: 'penalty_receipt.pdf', icon: IconReceipt, required: false },
|
||||
];
|
||||
|
||||
// Duplicate Post-Waiver check fields per BR-WVR-006/007 — same import/cargo case identifiers.
|
||||
function findDuplicatePostWaiver(app: WaiverApplication, all: WaiverApplication[]): WaiverApplication | null {
|
||||
if (app.waiverType !== 'Post-Waiver') return null;
|
||||
return all.find((other) =>
|
||||
other.id !== app.id &&
|
||||
other.waiverType === 'Post-Waiver' &&
|
||||
(other.status === 'Approved' || other.status === 'Letter Generated' || other.status === 'Completed') &&
|
||||
other.tinNumber === app.tinNumber &&
|
||||
other.importCertNumber === app.importCertNumber &&
|
||||
other.invoiceNumber === app.invoiceNumber &&
|
||||
other.billOfLadingNumber === app.billOfLadingNumber &&
|
||||
other.vesselName === app.vesselName &&
|
||||
other.portOfLoading === app.portOfLoading &&
|
||||
other.portOfDischarge === app.portOfDischarge
|
||||
) ?? null;
|
||||
}
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function WaiverReviewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [fetchTrigger] = useApiMutation<WaiverApplication>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const [app, setApp] = useState<WaiverApplication | null>(null);
|
||||
const [status, setStatus] = useState<WaiverStatus>('Submitted');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: `/logistics-waivers/${id}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => { setApp(data); setStatus(data.status); setRemarks(data.remarks); })
|
||||
.catch(() => {
|
||||
const mock = MOCK_WAIVER_APPLICATIONS.find((a) => a.id === id) ?? null;
|
||||
setApp(mock);
|
||||
if (mock) { setStatus(mock.status); setRemarks(mock.remarks); }
|
||||
});
|
||||
}, [id, fetchTrigger]);
|
||||
|
||||
const isTerminal = status === 'Rejected' || status === 'Completed';
|
||||
const isPostWaiver = app?.waiverType === 'Post-Waiver';
|
||||
const duplicate = app ? findDuplicatePostWaiver(app, MOCK_WAIVER_APPLICATIONS) : null;
|
||||
|
||||
// Statuses assignable via the modal, gated by BR-WVR-014/015: no letter generation
|
||||
// before approval, and no Post-Waiver letter before penalty payment confirmation.
|
||||
const availableStatuses = (() => {
|
||||
const base = ['Under Review', 'Under Evaluation', 'Approved', 'Resubmit Required', 'Rejected'];
|
||||
if (!isPostWaiver) return [...base, 'Letter Generated', 'Completed'];
|
||||
return [...base, 'Penalty Payment Pending', 'Payment Confirmed', 'Letter Generated', 'Completed'];
|
||||
})();
|
||||
|
||||
const canSelectLetterGenerated = (s: string) => {
|
||||
if (s !== 'Letter Generated') return true;
|
||||
if (!isPostWaiver) return status === 'Approved' || status === 'Letter Generated';
|
||||
return status === 'Payment Confirmed' || status === 'Letter Generated';
|
||||
};
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!selectedStatus || !app) return;
|
||||
setActionLoading(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const newStatus = selectedStatus as WaiverStatus;
|
||||
const approvalDate = newStatus === 'Approved' ? new Date().toISOString().split('T')[0] : app.approvalDate;
|
||||
setApp((prev) => prev ? { ...prev, status: newStatus, approvalDate, remarks } : prev);
|
||||
setStatus(newStatus);
|
||||
setActionLoading(false);
|
||||
setModalOpen(false);
|
||||
notify.success(`Application status updated to ${newStatus}.`);
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<Stack gap="md" p="xl">
|
||||
<Text c="dimmed">Application not found.</Text>
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w={200} onClick={() => navigate('/waiver')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const docs = [...DOCS_COMMON, ...(isPostWaiver ? DOCS_POST_WAIVER : [])];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/waiver')}>
|
||||
Back to Queue
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<Badge variant="outline" size="lg" color={isPostWaiver ? 'grape' : 'blue'}>{app.waiverType}</Badge>
|
||||
<Badge color={STATUS_COLOR[status] ?? 'gray'} size="lg" variant="light">{status}</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShieldOff size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{app.companyName}</Title>
|
||||
<Text fz="sm" c="dimmed">{app.id} · Maritime Logistics Waiver</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{duplicate && status !== 'Rejected' && (
|
||||
<Alert icon={<IconAlertTriangle size={17} />} color="red" title="Duplicate Post-Waiver Detected">
|
||||
An already {duplicate.status === 'Letter Generated' || duplicate.status === 'Completed' ? 'letter-generated' : 'approved'} Post-Waiver
|
||||
application ({duplicate.id}) exists for the same TIN, import certificate, invoice, bill of lading, vessel, and port combination.
|
||||
Post-Waiver is issued only one time per import/cargo case — do not approve this application.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(status === 'Letter Generated' || status === 'Completed') && (
|
||||
<Alert icon={<IconCircleCheck size={17} />} color="teal" title="Waiver Letter Generated">
|
||||
Letter generated on {app.approvalDate}.
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'Rejected' && (
|
||||
<Alert icon={<IconX size={17} />} color="red" title="Application Rejected">
|
||||
This application has been rejected. No further changes can be made.
|
||||
</Alert>
|
||||
)}
|
||||
{isPostWaiver && status === 'Penalty Payment Pending' && (
|
||||
<Alert icon={<IconAlertTriangle size={17} />} color="grape" title="Penalty Payment Pending">
|
||||
Applicant must pay the penalty ({app.penaltyAmount?.toLocaleString() ?? '—'} ETB) before the waiver letter can be generated.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTerminal && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">Take Action</Text>
|
||||
<Button size="sm" leftSection={<IconEdit size={16} />} onClick={() => { setSelectedStatus(null); setModalOpen(true); }}>
|
||||
Update Status
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Applicant Information</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Company Name" value={app.companyName} />
|
||||
<InfoRow label="TIN Number" value={app.tinNumber} />
|
||||
<InfoRow label="Import Certificate No." value={app.importCertNumber} />
|
||||
<InfoRow label="Waiver Type" value={app.waiverType} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Shipment & Vessel</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Invoice Number" value={app.invoiceNumber} />
|
||||
<InfoRow label="Bill of Lading No." value={app.billOfLadingNumber} />
|
||||
<InfoRow label="Vessel Name" value={app.vesselName} />
|
||||
<InfoRow label="Port of Loading" value={app.portOfLoading} />
|
||||
<InfoRow label="Port of Discharge" value={app.portOfDischarge} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{isPostWaiver && (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Penalty Payment</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Penalty Amount" value={app.penaltyAmount ? `${app.penaltyAmount.toLocaleString()} ETB` : 'Not yet assessed'} />
|
||||
<InfoRow label="Payment Status" value={app.penaltyPaid ? 'Paid' : 'Not Paid'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap="md">
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Uploaded Documents</Text>
|
||||
<Stack gap="xs">
|
||||
{docs.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
const ok = app.docsComplete;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color={ok ? 'teal' : 'red'} variant="light">
|
||||
<DocIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} fz="xs">
|
||||
{doc.label}{doc.required && <Text span c="red" ml={3}>*</Text>}
|
||||
</Text>
|
||||
{ok ? <Text fz="xs" c="dimmed">{doc.fileName}</Text> : <Text fz="xs" c="red">Not uploaded</Text>}
|
||||
</div>
|
||||
</Group>
|
||||
{ok ? (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />}>View</Button>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<ThemeIcon size={22} radius="xl" color="red" variant="light"><IconX size={13} /></ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Remarks</Text>
|
||||
<Text fz="sm" c={app.remarks ? undefined : 'dimmed'}>{app.remarks || 'No remarks.'}</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Text fw={700} fz="sm" mb="sm">Application Timeline</Text>
|
||||
<Stack gap={6}>
|
||||
<InfoRow label="Submitted Date" value={app.submittedDate} />
|
||||
<InfoRow label="Approval Date" value={app.approvalDate ?? 'Pending'} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
{(status === 'Letter Generated' || status === 'Completed') && (
|
||||
<Paper withBorder radius="md" p="lg" style={{ borderColor: 'var(--mantine-color-teal-4)', borderWidth: 2 }}>
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="filled">
|
||||
<IconFileCertificate size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="md" c="teal.7">EMA Waiver Letter — {app.waiverType}</Text>
|
||||
<Text fz="xs" c="dimmed">Generated on {app.approvalDate}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Card withBorder radius="md" p="md" style={{ background: 'var(--mantine-color-teal-light)' }}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="xs">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={32} radius="sm" color="teal" variant="filled"><IconFileCertificate size={16} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Official EMA Waiver Letter (Bank Copy)</Text>
|
||||
<Text fz="xs" c="dimmed">Addressed to bank for import clearance</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="white" color="teal" leftSection={<IconEye size={12} />} style={{ flex: 1 }}>Preview</Button>
|
||||
<Button size="xs" color="teal" leftSection={<IconDownload size={12} />} style={{ flex: 1 }}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Update Application Status" size="md">
|
||||
<Stack gap="md">
|
||||
{duplicate && (
|
||||
<Alert icon={<IconAlertTriangle size={16} />} color="red">
|
||||
Duplicate Post-Waiver case ({duplicate.id}) already exists — approval is not recommended.
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
label="New Status"
|
||||
placeholder="Select status"
|
||||
data={availableStatuses.map((s) => ({ value: s, label: s, disabled: !canSelectLetterGenerated(s) }))}
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
/>
|
||||
{selectedStatus === 'Letter Generated' && !canSelectLetterGenerated(selectedStatus) && (
|
||||
<Alert color="orange" fz="xs">
|
||||
{isPostWaiver
|
||||
? 'Letter can only be generated after penalty payment is confirmed.'
|
||||
: 'Letter can only be generated after approval.'}
|
||||
</Alert>
|
||||
)}
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Add notes for the applicant..."
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
color={selectedStatus === 'Approved' || selectedStatus === 'Letter Generated' || selectedStatus === 'Completed' ? 'teal' : selectedStatus === 'Rejected' ? 'red' : 'blue'}
|
||||
loading={actionLoading}
|
||||
disabled={!selectedStatus || !canSelectLetterGenerated(selectedStatus)}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Confirm Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Waiver review"
|
||||
description="Waiver applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default WaiverReviewPage;
|
||||
|
||||
@@ -26,6 +26,30 @@ export const am: Translations = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
groupLicensing: 'ፈቃድ አሰጣጥ',
|
||||
allApplications: 'ሁሉም ማመልከቻዎች',
|
||||
certificateDesigner: 'የምስክር ወረቀት ንድፍ',
|
||||
byType: 'በዓይነት',
|
||||
typeFreightForwarder: 'የጭነት አስተላላፊ',
|
||||
typeShippingAgent: 'የመርከብ ወኪል',
|
||||
typeCombined: 'ጥምር የመርከብ ወኪል እና ጭነት አስተላላፊ',
|
||||
typeJointInvestment: 'የጋራ ኢንቨስትመንት',
|
||||
typeMto: 'የብዝሃ-ሁነታ ትራንስፖርት አንቀሳቃሽ',
|
||||
primary: 'ዋና',
|
||||
destinations: 'ወደ',
|
||||
noResults: 'ምንም አልተገኘም',
|
||||
commandPlaceholder: 'ማያ ገጾችን፣ ማመልከቻዎችን፣ ኩባንያዎችን፣ ቲን ይፈልጉ…',
|
||||
pending: 'በመጠባበቅ ላይ ያሉ',
|
||||
pendingCount_one: '{{count}} በመጠባበቅ ላይ',
|
||||
pendingCount_other: '{{count}} በመጠባበቅ ላይ',
|
||||
groupSeafarer: 'የመርከበኞች አገልግሎት',
|
||||
groupVessels: 'መርከቦች',
|
||||
groupExaminations: 'ፈተናዎች',
|
||||
groupAdministration: 'አስተዳደር',
|
||||
groupAccount: 'መለያ',
|
||||
soon: 'በቅርቡ',
|
||||
details: 'ዝርዝር',
|
||||
licenceReview: 'የፈቃድ ማመልከቻዎች',
|
||||
vesselRegistrationHeadDashboard: 'የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ',
|
||||
vesselRegistrationQueue: 'የመርከብ ምዝገባ ወረፋ',
|
||||
vesselFormBuilder: 'የመርከብ ቅጽ መገንቢያ',
|
||||
@@ -612,4 +636,293 @@ export const am: Translations = {
|
||||
departmentRequired: 'ክፍል ያስፈልጋል',
|
||||
},
|
||||
},
|
||||
|
||||
queue: {
|
||||
title: 'የፈቃድ ማመልከቻዎች',
|
||||
search: 'ፍለጋ',
|
||||
searchPlaceholder: 'ኩባንያ፣ ቲን ወይም ቁጥር',
|
||||
status: 'ሁኔታ',
|
||||
anyStatus: 'ማንኛውም',
|
||||
type: 'የፈቃድ ዓይነት',
|
||||
anyType: 'ማንኛውም',
|
||||
typeCol: 'ዓይነት',
|
||||
statusCol: 'ሁኔታ',
|
||||
submittedFrom: 'ከቀን ጀምሮ የቀረበ',
|
||||
submittedTo: 'እስከ ቀን የቀረበ',
|
||||
clearFilters: 'አጽዳ',
|
||||
refresh: 'አድስ',
|
||||
export: 'ወደ CSV ላክ',
|
||||
exportTruncated: 'ወደ ውጭ መላክ ተቆርጧል',
|
||||
exportTruncatedBody: 'ከ{{total}} ረድፎች ውስጥ የመጀመሪያዎቹ {{exported}} ተልከዋል። ለቀሪው ማጣሪያውን ያጥቡ።',
|
||||
exportFailed: 'ወደ ውጭ መላክ አልተሳካም',
|
||||
comfortable: 'ሰፊ',
|
||||
compact: 'ጥብቅ',
|
||||
number: 'ማመልከቻ ቁ.',
|
||||
company: 'ኩባንያ',
|
||||
tin: 'ቲን',
|
||||
submitted: 'የቀረበበት',
|
||||
sla: 'ዕድሜ / የጊዜ ገደብ',
|
||||
claim: 'ውሰድ',
|
||||
review: 'ገምግም',
|
||||
claimed: 'ተወስዷል',
|
||||
claimedBody: 'ማመልከቻው አሁን ለእርስዎ ተመድቧል።',
|
||||
claimFailed: 'መውሰድ አልተቻለም',
|
||||
claimRace: 'ሌላ ሹም አስቀድሞ ወስዶታል።',
|
||||
bulkClaim_one: '{{count}} ውሰድ',
|
||||
bulkClaim_other: '{{count}} ውሰድ',
|
||||
bulkClaimed_one: '{{count}} ተወስዷል',
|
||||
bulkClaimed_other: '{{count}} ተወስደዋል',
|
||||
bulkClaimPartial_one: '{{count}} አስቀድሞ በሌላ ሹም ተወስዷል።',
|
||||
bulkClaimPartial_other: '{{count}} አስቀድሞ በሌሎች ሹማምንት ተወስደዋል።',
|
||||
selectAll: 'ሁሉንም ምረጥ',
|
||||
selectRow: '{{number}} ምረጥ',
|
||||
selectedCount_one: '{{count}} ተመርጧል',
|
||||
selectedCount_other: '{{count}} ተመርጠዋል',
|
||||
showing: 'ከ{{total}} ውስጥ {{from}}–{{to}} በማሳየት ላይ',
|
||||
empty: 'እዚህ የሚጠብቅ ነገር የለም',
|
||||
emptyBody: 'አዲስ ማመልከቻዎች ሲቀርቡ እዚህ ይታያሉ።',
|
||||
emptyFiltered: 'በእነዚህ ማጣሪያዎች የሚዛመድ ማመልከቻ የለም',
|
||||
emptyFilteredBody: 'ማጣሪያዎቹን ለማስፋት ወይም ለማጽዳት ይሞክሩ።',
|
||||
errorTitle: 'ወረፋውን መጫን አልተቻለም',
|
||||
views: {
|
||||
unassigned: 'ያልተመደበ',
|
||||
mine: 'የእኔ ወረፋ',
|
||||
awaitingApplicant: 'አመልካችን በመጠባበቅ',
|
||||
overdue: 'ጊዜው ያለፈበት',
|
||||
readyToIssue: 'ለመስጠት ዝግጁ',
|
||||
all: 'ሁሉም',
|
||||
},
|
||||
},
|
||||
|
||||
review: {
|
||||
summary: 'ማጠቃለያ',
|
||||
officer: 'ሹም',
|
||||
supervisor: 'የበላይ ኃላፊ',
|
||||
officerPlaceholder: 'ማን እንደሚረከበው ይምረጡ',
|
||||
noOfficers: 'ምንም ሹም አልተገኘም',
|
||||
typeToConfirm: 'ለማረጋገጥ {{number}} ይተይቡ',
|
||||
confirmMismatch: 'አይዛመድም',
|
||||
type: 'ዓይነት',
|
||||
tin: 'ቲን',
|
||||
kind: 'ዓይነት',
|
||||
submitted: 'የቀረበበት',
|
||||
slaLabel: 'የጊዜ ገደብ',
|
||||
eligibility: 'ብቁነት',
|
||||
statusTimeline: 'ሂደት',
|
||||
assigned: 'ተመድቧል',
|
||||
decisionBar: 'የውሳኔ አሞሌ',
|
||||
moreActions: 'ተጨማሪ ተግባራት',
|
||||
irreversible: 'መመለስ አይቻልም',
|
||||
irreversibleWarning: 'ይህ ውሳኔ የመጨረሻ ሲሆን ከጀርባ ቢሮ መመለስ አይቻልም።',
|
||||
irreversibleAck: 'ይህ የመጨረሻ መሆኑን ተረድቻለሁ',
|
||||
reasonCode: 'ምክንያት',
|
||||
reasonCodePlaceholder: 'ምክንያት ይምረጡ',
|
||||
reasonDetail: 'ለአመልካቹ ዝርዝር',
|
||||
reasonDetailHint: 'ይህ ጽሑፍ ለአመልካቹ እንዳለ ይላካል።',
|
||||
deficiencies: 'አመልካቹ ማስተካከል ያለበት ነገሮች',
|
||||
deficienciesHint: 'የተመረጡት ብቻ ለአመልካቹ ሊስተካከሉ ይችላሉ።',
|
||||
notificationPreview: 'ለአመልካቹ የሚላክ መልእክት',
|
||||
notificationPreviewHint: 'በኤስኤምኤስ እና ኢሜይል ይላካል። ከማረጋገጥዎ በፊት ያስተካክሉ።',
|
||||
needsCorrection: 'ማስተካከያ ያስፈልገዋል',
|
||||
correctionPlaceholder: 'አመልካቹ ምን ማስተካከል አለበት?',
|
||||
verifiedCapital: 'የተረጋገጠ ካፒታል (ብር)',
|
||||
capitalHint: 'ዝቅተኛ {{min}} — ከባንክ ደብዳቤ ጋር ያረጋግጡ',
|
||||
capitalHintNoMin: 'ከባንክ ደብዳቤ ጋር ተረጋግጧል',
|
||||
capitalLocked: 'በዚህ ደረጃ ካፒታል ማስተካከል አይቻልም።',
|
||||
belowMinimum: 'ከ{{min}} ዝቅተኛ በታች',
|
||||
declared: 'አመልካቹ ያሳወቀው',
|
||||
role: 'ሚና',
|
||||
name: 'ስም',
|
||||
evidence: 'ማስረጃ',
|
||||
noInspections: 'እስካሁን ምርመራ አልተያዘም።',
|
||||
unscheduled: 'አልተያዘም',
|
||||
inspectionResult: 'የምርመራ ውጤት',
|
||||
findings: 'ግኝቶች',
|
||||
dateTime: 'ቀን እና ሰዓት',
|
||||
schedule: 'ያዝ',
|
||||
pickDate: 'መጀመሪያ ቀን እና ሰዓት ይምረጡ',
|
||||
passed: 'አልፏል',
|
||||
failed: 'ወድቋል',
|
||||
round_one: 'ዙር {{count}}',
|
||||
round_other: 'ዙር {{count}}',
|
||||
theApplicant: 'አመልካቹ',
|
||||
linkCopied: 'አገናኝ ተቀድቷል',
|
||||
actionFailed: 'ተግባሩ አልተሳካም',
|
||||
errorTitle: 'ይህን ማመልከቻ መጫን አልተቻለም',
|
||||
hideActivity: 'እንቅስቃሴ ደብቅ',
|
||||
showActivity: 'እንቅስቃሴ አሳይ',
|
||||
awaitingPayment: 'አመልካቹ {{amount}} {{currency}} እንዲከፍል በመጠባበቅ ላይ።',
|
||||
tabs: {
|
||||
overview: 'አጠቃላይ እይታ',
|
||||
financials: 'የገንዘብ መረጃ',
|
||||
documents: 'ሰነዶች',
|
||||
staff: 'ሠራተኞች',
|
||||
inspection: 'ምርመራ',
|
||||
},
|
||||
actions: {
|
||||
claim: 'ውሰድ',
|
||||
assign: 'መድብ',
|
||||
escalate: 'ወደ ላይ አሳድግ',
|
||||
hold: 'አግድ',
|
||||
resume: 'ቀጥል',
|
||||
completeReview: 'ግምገማ አጠናቅቅ',
|
||||
approveDocuments: 'ሰነዶችን አጽድቅ',
|
||||
scheduleInspection: 'ምርመራ ያዝ',
|
||||
recordInspection: 'የምርመራ ውጤት መዝግብ',
|
||||
finalApprove: 'አጽድቅ እና ስጥ',
|
||||
requestAdjustment: 'ማስተካከያ ጠይቅ',
|
||||
reject: 'አትቀበል',
|
||||
confirmPayment: 'ክፍያ አረጋግጥ',
|
||||
print: 'ሰነድ አትም',
|
||||
copyLink: 'አገናኝ ቅዳ',
|
||||
downloadDocuments: 'ሁሉንም ሰነዶች አውርድ',
|
||||
generateCertificate: 'ሰርተፍኬት አዘጋጅ',
|
||||
auditTrail: 'የኦዲት መዝገብ አሳይ',
|
||||
},
|
||||
disabled: {
|
||||
wrongStatus: 'በዚህ ደረጃ አይገኝም',
|
||||
notAssigned: 'ለሌላ ሹም ተመድቧል',
|
||||
noPermission: 'ፈቃድ የለዎትም',
|
||||
needsFlags: 'ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ',
|
||||
needsCapital: 'መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ',
|
||||
needsInspection: 'የምርመራ ውጤት ያስፈልጋል',
|
||||
},
|
||||
reasons: {
|
||||
incompleteDocuments: 'ያልተሟሉ ሰነዶች',
|
||||
belowCapital: 'ካፒታል ከሚያስፈልገው በታች',
|
||||
failedInspection: 'ምርመራ ወድቋል',
|
||||
ineligibleApplicant: 'አመልካቹ ብቁ አይደለም',
|
||||
duplicateApplication: 'ተደጋጋሚ ማመልከቻ',
|
||||
illegibleDocument: 'ሰነዱ አይነበብም',
|
||||
expiredDocument: 'ሰነዱ ጊዜው አልፎበታል',
|
||||
missingDocument: 'ሰነዱ ጠፍቷል',
|
||||
inconsistentDetails: 'ዝርዝሮቹ ከሰነዶቹ ጋር አይዛመዱም',
|
||||
awaitingThirdParty: 'የሶስተኛ ወገን ማረጋገጫ በመጠባበቅ',
|
||||
legalProceedings: 'በሕግ ሂደት ላይ',
|
||||
applicantRequest: 'በአመልካቹ ጥያቄ',
|
||||
aboveAuthority: 'ከእኔ የማጽደቅ ሥልጣን በላይ',
|
||||
policyUnclear: 'የፖሊሲ መመሪያ ያስፈልጋል',
|
||||
conflictOfInterest: 'የጥቅም ግጭት',
|
||||
},
|
||||
consequences: {
|
||||
fallback: 'ይህ ለ{{applicant}} ማመልከቻ {{number}} ያዘምናል።',
|
||||
'final-approve': 'ለ{{applicant}} ማመልከቻ {{number}} ያጸድቃል እና የሰርተፍኬት አሰጣጥ ይጀምራል።',
|
||||
reject: 'ለ{{applicant}} ማመልከቻ {{number}} አይቀበልም። ይህ ማመልከቻውን ያጠናቅቃል።',
|
||||
'request-adjustment': 'ማመልከቻ {{number}} ለማስተካከያ ወደ {{applicant}} ይመልሳል።',
|
||||
hold: 'ለ{{applicant}} ማመልከቻ {{number}} ያግዳል። ለእርስዎ ተመድቦ ይቆያል።',
|
||||
resume: 'ማመልከቻ {{number}} ወደ ታገደበት ደረጃ ይመልሳል።',
|
||||
escalate: 'ማመልከቻ {{number}} ለውሳኔ ወደ የበላይ ኃላፊ ያሳድጋል።',
|
||||
'confirm-payment': 'ለማመልከቻ {{number}} ክፍያ ያረጋግጣል።',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'ውድ {{applicant}}፣ በማመልከቻ {{number}} ላይ ዝማኔ አለ፦ {{action}}።',
|
||||
'final-approve': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ጸድቋል። ሰርተፍኬትዎ በዝግጅት ላይ ነው።',
|
||||
reject: 'ውድ {{applicant}}፣ ማመልከቻ {{number}} አልጸደቀም። እባክዎ ከታች ያለውን ምክንያት ይመልከቱ።',
|
||||
'request-adjustment': 'ውድ {{applicant}}፣ ማመልከቻ {{number}} ከመቀጠሉ በፊት ማስተካከያ ያስፈልገዋል።',
|
||||
},
|
||||
activity: {
|
||||
title: 'እንቅስቃሴ እና የኦዲት መዝገብ',
|
||||
empty: 'እስካሁን የተመዘገበ እንቅስቃሴ የለም።',
|
||||
system: 'ሲስተም',
|
||||
officer: 'ሹም',
|
||||
applicant: 'አመልካች',
|
||||
remarkOn: 'በ{{target}} ላይ ማስተካከያ ተጠይቋል',
|
||||
uploaded: '{{document}} ተጭኗል',
|
||||
},
|
||||
documents: {
|
||||
completeness: 'የሚያስፈልጉ ሰነዶች',
|
||||
accepted: 'ተቀባይነት አግኝቷል',
|
||||
rejected: 'ተቀባይነት አላገኘም',
|
||||
accept: 'ተቀበል',
|
||||
clear: 'ውሳኔ አጽዳ',
|
||||
confirmReject: 'አትቀበል',
|
||||
includeInAdjustment: 'መልስ',
|
||||
adjustmentNote: 'አመልካቹ ምን ማስተካከል አለበት?',
|
||||
nothingToJudge: 'የሚገመገም ምንም አልተጫነም',
|
||||
reviewedBy: 'በ{{name}} ተገምግሟል',
|
||||
saveFailed: 'ውሳኔውን ማስቀመጥ አልተቻለም',
|
||||
completenessLabel: '{{value}}% የሚያስፈልጉ ሰነዶች ተጭነዋል',
|
||||
missing: 'እስካሁን አልተጫነም',
|
||||
flagged: 'ማስተካከያ ተጠይቋል',
|
||||
view: 'እይ',
|
||||
preview: 'ቅድመ እይታ',
|
||||
download: 'አውርድ',
|
||||
downloadShort: 'አውርድ',
|
||||
reject: 'አትቀበል',
|
||||
rejectReason: 'ይህ ሰነድ ለምን መስተካከል አለበት?',
|
||||
reasonRequired: 'ምክንያት ያስፈልጋል',
|
||||
noFile: 'ፋይል የለም',
|
||||
noFileUploaded: 'እስካሁን ምንም አልተጫነም',
|
||||
noInlinePreview: 'ይህ የፋይል ዓይነት በአሳሹ ውስጥ ቅድመ እይታ አይደረግም።',
|
||||
},
|
||||
done: {
|
||||
completeReview: 'ግምገማ ተጠናቋል',
|
||||
approveDocuments: 'ሰነዶች ጸድቀዋል',
|
||||
finalApprove: 'ጸድቋል',
|
||||
requestAdjustment: 'ማስተካከያ ተጠይቋል',
|
||||
reject: 'ማመልከቻው አልተቀበለም',
|
||||
confirmPayment: 'ክፍያ ተረጋግጧል',
|
||||
hold: 'ማመልከቻው ታግዷል',
|
||||
resume: 'ማመልከቻው ቀጥሏል',
|
||||
escalate: 'ወደ ላይ አድጓል',
|
||||
assign: 'እንደገና ተመድቧል',
|
||||
scheduled: 'ምርመራ ተይዟል',
|
||||
inspectionPassed: 'ምርመራ አልፏል',
|
||||
inspectionFailed: 'ምርመራ ወድቋል',
|
||||
},
|
||||
},
|
||||
|
||||
error: {
|
||||
reference: 'ማጣቀሻ',
|
||||
retry: 'እንደገና ሞክር',
|
||||
},
|
||||
|
||||
shortcuts: {
|
||||
title: 'የቁልፍ ሰሌዳ አቋራጮች',
|
||||
commandPalette: 'ሁሉንም ፈልግ',
|
||||
moveRow: 'በረድፎች መካከል ተንቀሳቀስ',
|
||||
openRow: 'የተመረጠውን ረድፍ ክፈት',
|
||||
claimRow: 'የተመረጠውን ረድፍ ውሰድ',
|
||||
dismiss: 'ምርጫ አጽዳ / ዝጋ',
|
||||
help: 'ይህን ዝርዝር አሳይ',
|
||||
},
|
||||
|
||||
designer: {
|
||||
title: 'የምስክር ወረቀት ንድፍ',
|
||||
subtitle: 'ለፈቃድ ባለቤቶች የሚሰጠውን የምስክር ወረቀት ይንደፉ፣ የሚቆይበትንም ጊዜ ያዘጋጁ።',
|
||||
licenceType: 'የፈቃድ ዓይነት',
|
||||
validityYears: 'የሚቆይበት (ዓመታት)',
|
||||
validityHint: 'ፈቃድ ሲሰጥ ተግባራዊ ይሆናል',
|
||||
saveValidity: 'የሚቆይበትን ጊዜ አስቀምጥ',
|
||||
validitySaved: 'የሚቆይበት ጊዜ ተዘምኗል',
|
||||
newVersion: 'አዲስ ስሪት',
|
||||
versions: 'ስሪቶች',
|
||||
name: 'የስሪት ስም',
|
||||
landscape: 'አግድም',
|
||||
source: 'ቅንብር (Handlebars + HTML)',
|
||||
variables: 'ቦታ ያዢዎች',
|
||||
variablesHint: 'በጠቋሚው ቦታ ለማስገባት ይጫኑ።',
|
||||
preview: 'PDF ቅድመ እይታ',
|
||||
previewFailed: 'ቅድመ እይታውን ማዘጋጀት አልተቻለም',
|
||||
save: 'ረቂቅ አስቀምጥ',
|
||||
saved: 'ረቂቅ ተቀምጧል',
|
||||
saveFirst: 'መጀመሪያ ለውጦችዎን ያስቀምጡ',
|
||||
publish: 'አትም',
|
||||
published: 'ንድፉ ታትሟል',
|
||||
publishHint: 'ይህንን ቀጥታ የምስክር ወረቀት ንድፍ ያደርገዋል',
|
||||
publishedLocked: 'ይህ ስሪት ቀጥታ ላይ ስለሆነ ማስተካከል አይቻልም — ከእሱ የምስክር ወረቀቶች ተሰጥተዋል። ለውጥ ለማድረግ አዲስ ስሪት ይፍጠሩ።',
|
||||
archive: 'አንሳ',
|
||||
archived: 'ንድፉ ተነስቷል',
|
||||
delete: 'ረቂቅ ሰርዝ',
|
||||
deleted: 'ረቂቅ ተሰርዟል',
|
||||
create: 'ፍጠር',
|
||||
created: 'ረቂቅ ተፈጥሯል',
|
||||
newHint: 'ከቀጥታ ንድፉ ወይም ይህ ዓይነት ከሌለው ከውስጠ-ግንብ ቅንብር ይጀምራል።',
|
||||
empty: 'ለዚህ የፈቃድ ዓይነት እስካሁን ንድፍ የለም',
|
||||
emptyBody: 'የምስክር ወረቀቶች አሁን ውስጠ-ግንብ ቅንብር ይጠቀማሉ። ለመቆጣጠር ስሪት ይፍጠሩ።',
|
||||
loadFailed: 'ንድፎቹን መጫን አልተቻለም',
|
||||
actionFailed: 'ተግባሩ አልተሳካም',
|
||||
noPermission: 'ፈቃድ የለዎትም',
|
||||
noPublishPermission: 'ንድፎችን ማተም አይችሉም',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,30 @@ export const en = {
|
||||
},
|
||||
|
||||
nav: {
|
||||
groupLicensing: 'Licensing',
|
||||
allApplications: 'All Applications',
|
||||
certificateDesigner: 'Certificate Designer',
|
||||
byType: 'By Type',
|
||||
typeFreightForwarder: 'Freight Forwarder',
|
||||
typeShippingAgent: 'Shipping Agent',
|
||||
typeCombined: 'Combined SA + FF',
|
||||
typeJointInvestment: 'Joint Investment',
|
||||
typeMto: 'Multimodal Transport Operator',
|
||||
primary: 'Primary',
|
||||
destinations: 'Go to',
|
||||
noResults: 'Nothing found',
|
||||
commandPlaceholder: 'Search screens, applications, companies, TIN…',
|
||||
pending: 'Items pending',
|
||||
pendingCount_one: '{{count}} pending',
|
||||
pendingCount_other: '{{count}} pending',
|
||||
groupSeafarer: 'Seafarer Services',
|
||||
groupVessels: 'Vessels',
|
||||
groupExaminations: 'Examinations',
|
||||
groupAdministration: 'Administration',
|
||||
groupAccount: 'Account',
|
||||
soon: 'Soon',
|
||||
details: 'Details',
|
||||
licenceReview: 'Licence Applications',
|
||||
menu: 'MENU',
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
@@ -611,6 +635,295 @@ export const en = {
|
||||
departmentRequired: 'Department is required',
|
||||
},
|
||||
},
|
||||
|
||||
queue: {
|
||||
title: 'Licence applications',
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Company, TIN or number',
|
||||
status: 'Status',
|
||||
anyStatus: 'Any',
|
||||
type: 'Licence type',
|
||||
anyType: 'Any',
|
||||
typeCol: 'Type',
|
||||
statusCol: 'Status',
|
||||
submittedFrom: 'Submitted from',
|
||||
submittedTo: 'Submitted to',
|
||||
clearFilters: 'Clear',
|
||||
refresh: 'Refresh',
|
||||
export: 'Export CSV',
|
||||
exportTruncated: 'Export truncated',
|
||||
exportTruncatedBody: 'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||
exportFailed: 'Export failed',
|
||||
comfortable: 'Comfortable',
|
||||
compact: 'Compact',
|
||||
number: 'App #',
|
||||
company: 'Company',
|
||||
tin: 'TIN',
|
||||
submitted: 'Submitted',
|
||||
sla: 'Age / SLA',
|
||||
claim: 'Claim',
|
||||
review: 'Review',
|
||||
claimed: 'Claimed',
|
||||
claimedBody: 'The application is now assigned to you.',
|
||||
claimFailed: 'Could not claim',
|
||||
claimRace: 'Another officer already claimed it.',
|
||||
bulkClaim_one: 'Claim {{count}}',
|
||||
bulkClaim_other: 'Claim {{count}}',
|
||||
bulkClaimed_one: '{{count}} claimed',
|
||||
bulkClaimed_other: '{{count}} claimed',
|
||||
bulkClaimPartial_one: '{{count}} was already taken by another officer.',
|
||||
bulkClaimPartial_other: '{{count}} were already taken by another officer.',
|
||||
selectAll: 'Select all',
|
||||
selectRow: 'Select {{number}}',
|
||||
selectedCount_one: '{{count}} selected',
|
||||
selectedCount_other: '{{count}} selected',
|
||||
showing: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
empty: 'Nothing waiting here',
|
||||
emptyBody: 'New applications will appear here as they are submitted.',
|
||||
emptyFiltered: 'No applications match these filters',
|
||||
emptyFilteredBody: 'Try widening or clearing the filters.',
|
||||
errorTitle: 'Could not load the queue',
|
||||
views: {
|
||||
unassigned: 'Unassigned',
|
||||
mine: 'My Queue',
|
||||
awaitingApplicant: 'Awaiting Applicant',
|
||||
overdue: 'Overdue',
|
||||
readyToIssue: 'Ready to Issue',
|
||||
all: 'All',
|
||||
},
|
||||
},
|
||||
|
||||
review: {
|
||||
summary: 'Summary',
|
||||
officer: 'Officer',
|
||||
supervisor: 'Supervisor',
|
||||
officerPlaceholder: 'Select who takes this on',
|
||||
noOfficers: 'No officers found',
|
||||
typeToConfirm: 'Type {{number}} to confirm',
|
||||
confirmMismatch: 'Does not match',
|
||||
type: 'Type',
|
||||
tin: 'TIN',
|
||||
kind: 'Kind',
|
||||
submitted: 'Submitted',
|
||||
slaLabel: 'SLA',
|
||||
eligibility: 'Eligibility',
|
||||
statusTimeline: 'Progress',
|
||||
assigned: 'Assigned',
|
||||
decisionBar: 'Decision bar',
|
||||
moreActions: 'More actions',
|
||||
irreversible: 'Cannot be undone',
|
||||
irreversibleWarning: 'This decision is final and cannot be undone from the backoffice.',
|
||||
irreversibleAck: 'I understand this is final',
|
||||
reasonCode: 'Reason',
|
||||
reasonCodePlaceholder: 'Select a reason',
|
||||
reasonDetail: 'Details for the applicant',
|
||||
reasonDetailHint: 'This text is sent to the applicant verbatim.',
|
||||
deficiencies: 'Items the applicant must correct',
|
||||
deficienciesHint: 'Only the ticked items become editable for the applicant.',
|
||||
notificationPreview: 'Message to the applicant',
|
||||
notificationPreviewHint: 'Sent by SMS and email. Edit before confirming if needed.',
|
||||
needsCorrection: 'Needs correction',
|
||||
correctionPlaceholder: 'What must the applicant correct?',
|
||||
verifiedCapital: 'Verified capital (ETB)',
|
||||
capitalHint: 'Minimum {{min}} — check against the bank letter',
|
||||
capitalHintNoMin: 'Checked against the bank letter',
|
||||
capitalLocked: 'Capital can no longer be edited at this stage.',
|
||||
belowMinimum: 'Below the {{min}} minimum',
|
||||
declared: 'Applicant declared',
|
||||
role: 'Role',
|
||||
name: 'Name',
|
||||
evidence: 'Evidence',
|
||||
noInspections: 'No inspection has been scheduled yet.',
|
||||
unscheduled: 'Not scheduled',
|
||||
inspectionResult: 'Inspection result',
|
||||
findings: 'Findings',
|
||||
dateTime: 'Date and time',
|
||||
schedule: 'Schedule',
|
||||
pickDate: 'Pick a date and time first',
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
round_one: 'round {{count}}',
|
||||
round_other: 'round {{count}}',
|
||||
theApplicant: 'the applicant',
|
||||
linkCopied: 'Link copied',
|
||||
actionFailed: 'Action failed',
|
||||
errorTitle: 'Could not load this application',
|
||||
hideActivity: 'Hide activity',
|
||||
showActivity: 'Show activity',
|
||||
awaitingPayment: 'Waiting for the applicant to pay {{amount}} {{currency}}.',
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
financials: 'Financials',
|
||||
documents: 'Documents',
|
||||
staff: 'Staff',
|
||||
inspection: 'Inspection',
|
||||
},
|
||||
actions: {
|
||||
claim: 'Claim',
|
||||
assign: 'Assign',
|
||||
escalate: 'Escalate',
|
||||
hold: 'Put on hold',
|
||||
resume: 'Resume',
|
||||
completeReview: 'Complete review',
|
||||
approveDocuments: 'Approve documents',
|
||||
scheduleInspection: 'Schedule inspection',
|
||||
recordInspection: 'Record inspection result',
|
||||
finalApprove: 'Approve & issue',
|
||||
requestAdjustment: 'Request adjustment',
|
||||
reject: 'Reject',
|
||||
confirmPayment: 'Confirm payment',
|
||||
print: 'Print dossier',
|
||||
copyLink: 'Copy link',
|
||||
downloadDocuments: 'Download all documents',
|
||||
generateCertificate: 'Generate certificate',
|
||||
auditTrail: 'Show audit trail',
|
||||
},
|
||||
disabled: {
|
||||
wrongStatus: 'Not available at this stage',
|
||||
notAssigned: 'Assigned to another officer',
|
||||
noPermission: 'You do not have permission',
|
||||
needsFlags: 'Flag at least one item to request a correction',
|
||||
needsCapital: 'Record the verified capital first',
|
||||
needsInspection: 'Requires an inspection result',
|
||||
},
|
||||
reasons: {
|
||||
incompleteDocuments: 'Incomplete documents',
|
||||
belowCapital: 'Capital below the required minimum',
|
||||
failedInspection: 'Failed inspection',
|
||||
ineligibleApplicant: 'Applicant not eligible',
|
||||
duplicateApplication: 'Duplicate application',
|
||||
illegibleDocument: 'Document is illegible',
|
||||
expiredDocument: 'Document has expired',
|
||||
missingDocument: 'Document is missing',
|
||||
inconsistentDetails: 'Details do not match the documents',
|
||||
awaitingThirdParty: 'Awaiting third-party confirmation',
|
||||
legalProceedings: 'Subject to legal proceedings',
|
||||
applicantRequest: 'Requested by the applicant',
|
||||
aboveAuthority: 'Above my approval authority',
|
||||
policyUnclear: 'Policy guidance needed',
|
||||
conflictOfInterest: 'Conflict of interest',
|
||||
},
|
||||
consequences: {
|
||||
fallback: 'This updates application {{number}} for {{applicant}}.',
|
||||
'final-approve': 'Approves application {{number}} for {{applicant}} and starts certificate issuance.',
|
||||
reject: 'Rejects application {{number}} for {{applicant}}. This ends the application.',
|
||||
'request-adjustment': 'Returns application {{number}} to {{applicant}} for correction.',
|
||||
hold: 'Parks application {{number}} for {{applicant}}. It stays assigned to you.',
|
||||
resume: 'Returns application {{number}} to the stage it was held from.',
|
||||
escalate: 'Raises application {{number}} to a supervisor for a decision.',
|
||||
'confirm-payment': 'Confirms settlement for application {{number}}.',
|
||||
},
|
||||
notifications: {
|
||||
fallback: 'Dear {{applicant}}, there is an update on application {{number}}: {{action}}.',
|
||||
'final-approve': 'Dear {{applicant}}, application {{number}} has been approved. Your certificate is being prepared.',
|
||||
reject: 'Dear {{applicant}}, application {{number}} has not been approved. Please see the reason below.',
|
||||
'request-adjustment': 'Dear {{applicant}}, application {{number}} needs corrections before it can proceed.',
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity & audit trail',
|
||||
empty: 'No activity recorded yet.',
|
||||
system: 'System',
|
||||
officer: 'Officer',
|
||||
applicant: 'Applicant',
|
||||
remarkOn: 'Correction requested on {{target}}',
|
||||
uploaded: 'Uploaded {{document}}',
|
||||
},
|
||||
documents: {
|
||||
completeness: 'Required documents',
|
||||
accepted: 'Accepted',
|
||||
rejected: 'Rejected',
|
||||
accept: 'Accept',
|
||||
clear: 'Clear verdict',
|
||||
confirmReject: 'Reject',
|
||||
includeInAdjustment: 'Send back',
|
||||
adjustmentNote: 'What must the applicant correct?',
|
||||
nothingToJudge: 'Nothing uploaded to judge',
|
||||
reviewedBy: 'Reviewed by {{name}}',
|
||||
saveFailed: 'Could not save the verdict',
|
||||
completenessLabel: '{{value}}% of required documents uploaded',
|
||||
missing: 'Not yet uploaded',
|
||||
flagged: 'Correction requested',
|
||||
view: 'View',
|
||||
preview: 'Preview',
|
||||
download: 'Download',
|
||||
downloadShort: 'Download',
|
||||
reject: 'Reject',
|
||||
rejectReason: 'Why must this document be corrected?',
|
||||
reasonRequired: 'A reason is required',
|
||||
noFile: 'No file',
|
||||
noFileUploaded: 'Nothing uploaded yet',
|
||||
noInlinePreview: 'This file type cannot be previewed in the browser.',
|
||||
},
|
||||
done: {
|
||||
completeReview: 'Review completed',
|
||||
approveDocuments: 'Documents approved',
|
||||
finalApprove: 'Approved',
|
||||
requestAdjustment: 'Adjustment requested',
|
||||
reject: 'Application rejected',
|
||||
confirmPayment: 'Payment confirmed',
|
||||
hold: 'Application placed on hold',
|
||||
resume: 'Application resumed',
|
||||
escalate: 'Escalated',
|
||||
assign: 'Reassigned',
|
||||
scheduled: 'Inspection scheduled',
|
||||
inspectionPassed: 'Inspection passed',
|
||||
inspectionFailed: 'Inspection failed',
|
||||
},
|
||||
},
|
||||
|
||||
error: {
|
||||
reference: 'Reference',
|
||||
retry: 'Try again',
|
||||
},
|
||||
|
||||
shortcuts: {
|
||||
title: 'Keyboard shortcuts',
|
||||
commandPalette: 'Search everything',
|
||||
moveRow: 'Move between rows',
|
||||
openRow: 'Open the selected row',
|
||||
claimRow: 'Claim the selected row',
|
||||
dismiss: 'Clear selection / close',
|
||||
help: 'Show this list',
|
||||
},
|
||||
|
||||
designer: {
|
||||
title: 'Certificate designer',
|
||||
subtitle: 'Design the certificate issued to licence holders, and set how long it stays valid.',
|
||||
licenceType: 'Licence type',
|
||||
validityYears: 'Valid for (years)',
|
||||
validityHint: 'Applied when a licence is issued',
|
||||
saveValidity: 'Save validity',
|
||||
validitySaved: 'Validity updated',
|
||||
newVersion: 'New version',
|
||||
versions: 'Versions',
|
||||
name: 'Version name',
|
||||
landscape: 'Landscape',
|
||||
source: 'Template (Handlebars + HTML)',
|
||||
variables: 'Placeholders',
|
||||
variablesHint: 'Click to insert at the cursor.',
|
||||
preview: 'Preview PDF',
|
||||
previewFailed: 'Could not render the preview',
|
||||
save: 'Save draft',
|
||||
saved: 'Draft saved',
|
||||
saveFirst: 'Save your changes first',
|
||||
publish: 'Publish',
|
||||
published: 'Design published',
|
||||
publishHint: 'Makes this the live certificate design',
|
||||
publishedLocked: 'This version is live and cannot be edited — certificates have been issued from it. Create a new version to make changes.',
|
||||
archive: 'Withdraw',
|
||||
archived: 'Design withdrawn',
|
||||
delete: 'Delete draft',
|
||||
deleted: 'Draft deleted',
|
||||
create: 'Create',
|
||||
created: 'Draft created',
|
||||
newHint: 'Starts from the live design, or the built-in layout if this type has none.',
|
||||
empty: 'No design yet for this licence type',
|
||||
emptyBody: 'Certificates currently use the built-in layout. Create a version to take control of it.',
|
||||
loadFailed: 'Could not load the designs',
|
||||
actionFailed: 'Action failed',
|
||||
noPermission: 'You do not have permission',
|
||||
noPublishPermission: 'You cannot publish designs',
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -1,123 +1,28 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AppShell, rem } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logout } from "@ema-platform/auth";
|
||||
import { AppHeader, AppSidebar } from "@ema-platform/ui";
|
||||
import type { NavItem } from "@ema-platform/ui";
|
||||
import {
|
||||
IconAnchor,
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconLayoutDashboard,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconSettings,
|
||||
IconShip,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconShieldOff,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
IconReportAnalytics,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify } from "@ema-platform/ui";
|
||||
import { SUPPORTED_LANGUAGES } from "../i18n/config";
|
||||
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BrandMark, logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { NAV_SECTIONS } from './nav-config';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/dashboard", label: "nav.dashboard", icon: IconLayoutDashboard },
|
||||
{
|
||||
to: "/um/user-management/dashboard",
|
||||
label: "nav.userManagement",
|
||||
icon: IconUserShield,
|
||||
},
|
||||
{ to: "/seaman-book-queue", label: "nav.seamanBookQueue", icon: IconBook2 },
|
||||
{
|
||||
to: "/vessel-registration-head-dashboard",
|
||||
label: "nav.vesselRegistrationHeadDashboard",
|
||||
icon: IconGauge,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue",
|
||||
label: "nav.vesselRegistrationQueue",
|
||||
icon: IconAnchor,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-queue/new",
|
||||
label: "nav.vesselFormBuilder",
|
||||
icon: IconFilePlus,
|
||||
},
|
||||
{
|
||||
to: "/vessel-registration-report",
|
||||
label: "nav.vesselRegistrationReport",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
to: "/vessel-ownership-transfer",
|
||||
label: "nav.ownershipTransferQueue",
|
||||
icon: IconFileDescription,
|
||||
},
|
||||
{
|
||||
to: "/logistics-head-dashboard",
|
||||
label: "nav.logisticsHeadDashboard",
|
||||
icon: IconGauge,
|
||||
},
|
||||
{
|
||||
to: "/freight-forwarder-license",
|
||||
label: "nav.freightForwarderLicense",
|
||||
icon: IconTruck,
|
||||
},
|
||||
{
|
||||
to: "/shipping-agent-license",
|
||||
label: "nav.shippingAgentLicense",
|
||||
icon: IconShip,
|
||||
},
|
||||
{ to: "/combined-license", label: "nav.combinedLicense", icon: IconStack2 },
|
||||
{
|
||||
to: "/joint-investment-license",
|
||||
label: "nav.jointInvestmentLicense",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{ to: "/mto-license", label: "nav.mtoLicense", icon: IconTruck },
|
||||
{ to: "/waiver", label: "nav.waiver", icon: IconShieldOff },
|
||||
{ to: "/coc-queue", label: "nav.cocQueue", icon: IconShieldCheck },
|
||||
{
|
||||
to: "/endorsement-queue",
|
||||
label: "nav.endorsementQueue",
|
||||
icon: IconRubberStamp,
|
||||
},
|
||||
|
||||
{
|
||||
to: "/vessel-registration-report",
|
||||
label: "nav.vesselRegistrationReport",
|
||||
icon: IconReportAnalytics,
|
||||
},
|
||||
{ to: "/seafarer-registry", label: "nav.seafarerRegistry", icon: IconUsers },
|
||||
// { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
|
||||
{ to: "/payment-config", label: "nav.paymentConfig", icon: IconCreditCard },
|
||||
{ to: "/analytics", label: "nav.analytics", icon: IconChartBar },
|
||||
{
|
||||
to: "/medical-verification",
|
||||
label: "nav.medicalVerification",
|
||||
icon: IconHeart,
|
||||
},
|
||||
{ to: "/questions", label: "nav.questions", icon: IconQuestionMark },
|
||||
{ to: "/exams", label: "nav.exams", icon: IconClipboardList },
|
||||
{ to: "/exam-results", label: "nav.examResults", icon: IconReport },
|
||||
{ to: "/configuration", label: "nav.configuration", icon: IconSettings },
|
||||
{ to: "/profile", label: "nav.profile", icon: IconUser },
|
||||
];
|
||||
/**
|
||||
* How often the pending-work badges refresh.
|
||||
*
|
||||
* Polled on a timer rather than refetched per navigation: the counts sit in
|
||||
* the chrome and are visible on every screen, so tying them to route changes
|
||||
* would fire a request each time an officer clicked anything.
|
||||
*/
|
||||
const BADGE_POLL_MS = 60_000;
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
@@ -130,8 +35,46 @@ export function BackofficeLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { can } = usePermissions();
|
||||
|
||||
const displayName = user?.name?.en || user?.username || "";
|
||||
// Badges reflect real pending work. One grouped request on a timer, shared
|
||||
// by the sidebar and the top bar via the RTK cache.
|
||||
const { data: counts } = useGetQueueCountsQuery(undefined, {
|
||||
pollingInterval: BADGE_POLL_MS,
|
||||
refetchOnMountOrArgChange: false,
|
||||
});
|
||||
|
||||
const sections = useMemo<NavSection[]>(() => {
|
||||
const withBadges = NAV_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.to === '/licence-review' && counts?.unassigned
|
||||
? { ...item, badge: counts.unassigned }
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
return filterByPermissions(
|
||||
withBadges,
|
||||
// `can` already fails open when the token carries no permission claim,
|
||||
// so this only ever removes items we are sure the user cannot use.
|
||||
withBadges
|
||||
.flatMap((section) => section.items)
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.flatMap((item) => item.permissions ?? [])
|
||||
.filter((permission) => can([permission])),
|
||||
);
|
||||
}, [counts?.unassigned, can]);
|
||||
|
||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
() =>
|
||||
sections.flatMap((section) =>
|
||||
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
|
||||
),
|
||||
[sections],
|
||||
);
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const initials = displayName
|
||||
? displayName
|
||||
.split(/\s+/)
|
||||
@@ -146,14 +89,29 @@ export function BackofficeLayout() {
|
||||
navigate("/login");
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
// Label each crumb from the nav item it corresponds to, falling back to a
|
||||
// readable form of the path segment. Every crumb was previously labelled
|
||||
// "Dashboard", which made the trail useless.
|
||||
const crumbs = [
|
||||
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||
...segments
|
||||
.map((_, i) => "/" + segments.slice(0, i + 1).join("/"))
|
||||
.filter((path) => path !== "/dashboard")
|
||||
.filter((path) => !path.startsWith("/um"))
|
||||
.map((path) => ({ label: t("nav.dashboard"), path })),
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => path !== '/dashboard')
|
||||
.filter((path) => !path.startsWith('/um'))
|
||||
.map((path) => {
|
||||
const match = navItems.find((item) => item.to === path);
|
||||
if (match) return { label: t(match.label), path };
|
||||
const segment = path.split('/').pop() ?? '';
|
||||
// Ids get a generic label rather than a raw uuid in the trail.
|
||||
const isId = /^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment);
|
||||
return {
|
||||
label: isId
|
||||
? t('nav.details', 'Details')
|
||||
: segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
path,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const go = (item: NavItem) => {
|
||||
@@ -211,65 +169,21 @@ export function BackofficeLayout() {
|
||||
{!isSidebar && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: rem(2),
|
||||
padding: "0 32px",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 32px',
|
||||
height: 42,
|
||||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||
overflowX: "auto",
|
||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active =
|
||||
!!item.to &&
|
||||
(location.pathname === item.to ||
|
||||
location.pathname.startsWith(`${item.to}/`));
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
onClick={() => go(item)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: rem(6),
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderBottom: "2px solid",
|
||||
borderBottomColor: active
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "transparent",
|
||||
background: "transparent",
|
||||
color: active
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
transition: "all 150ms ease",
|
||||
height: "100%",
|
||||
marginBottom: -1,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active)
|
||||
e.currentTarget.style.color =
|
||||
"var(--mantine-color-blue-6)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active)
|
||||
e.currentTarget.style.color =
|
||||
"var(--mantine-color-gray-6)";
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={18} stroke={1.6} />
|
||||
<span>{t(item.label)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Grouped dropdowns. Previously every destination rendered as a
|
||||
sibling button in one horizontally scrolling row. */}
|
||||
<AppTopNav
|
||||
navItems={sections}
|
||||
activePath={location.pathname}
|
||||
onNavigate={go}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AppShell.Header>
|
||||
@@ -285,13 +199,14 @@ export function BackofficeLayout() {
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={NAV_ITEMS}
|
||||
navItems={sections}
|
||||
collapsed={collapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
onNavigate={go}
|
||||
brandName={t("app.name")}
|
||||
brandSubtitle={t("app.authority")}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
)}
|
||||
@@ -301,6 +216,9 @@ export function BackofficeLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
||||
<CommandPalette sections={sections} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
96
apps/backoffice/src/app/layouts/CommandPalette.tsx
Normal file
96
apps/backoffice/src/app/layouts/CommandPalette.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Spotlight, type SpotlightActionData } from '@mantine/spotlight';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconFileText, IconSearch } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { flattenNav, type NavSection } from '@ema-platform/ui';
|
||||
import { useGetAllApplicationsQuery } from '@ema-platform/api';
|
||||
|
||||
/** Long enough that typing a company name does not fire a request per keystroke. */
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
/** Below this, a server search matches too much to be useful. */
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
/** Already permission-filtered, so the palette cannot reach a hidden route. */
|
||||
sections: NavSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘K search over every destination and recent application.
|
||||
*
|
||||
* With twenty-plus destinations plus five licence types, hunting through
|
||||
* nested menus is the slow path. This makes nesting cheap: anything reachable
|
||||
* by clicking is reachable by typing, including applications by number,
|
||||
* company or TIN.
|
||||
*/
|
||||
export function CommandPalette({ sections }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced] = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const term = debounced.trim();
|
||||
// Only hits the API once the palette is open and the query is meaningful.
|
||||
const { data: applications } = useGetAllApplicationsQuery(
|
||||
{ search: term, take: 8 },
|
||||
{ skip: term.length < MIN_SEARCH_LENGTH },
|
||||
);
|
||||
|
||||
const destinationActions = useMemo<SpotlightActionData[]>(
|
||||
() =>
|
||||
flattenNav(sections)
|
||||
.filter((item) => item.to && !item.soon)
|
||||
.map((item) => ({
|
||||
id: item.to as string,
|
||||
label: t(item.label),
|
||||
description: item.to,
|
||||
leftSection: <item.icon size={18} stroke={1.6} />,
|
||||
onClick: () => navigate(item.to as string),
|
||||
})),
|
||||
[sections, navigate, t],
|
||||
);
|
||||
|
||||
const applicationActions = useMemo<SpotlightActionData[]>(
|
||||
() =>
|
||||
(applications?.items ?? []).map((app) => ({
|
||||
id: `application-${app.id}`,
|
||||
label: app.companyName ?? app.applicationNumber,
|
||||
description: [app.applicationNumber, app.tinNumber]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
leftSection: <IconFileText size={18} stroke={1.6} />,
|
||||
onClick: () => navigate(`/licence-review/${app.id}`),
|
||||
})),
|
||||
[applications, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Spotlight
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
actions={[
|
||||
{
|
||||
group: t('nav.destinations', 'Go to'),
|
||||
actions: destinationActions,
|
||||
},
|
||||
{
|
||||
group: t('nav.applications', 'Applications'),
|
||||
actions: applicationActions,
|
||||
},
|
||||
]}
|
||||
shortcut={['mod + K']}
|
||||
nothingFound={t('nav.noResults', 'Nothing found')}
|
||||
highlightQuery
|
||||
searchProps={{
|
||||
leftSection: <IconSearch size={18} stroke={1.6} />,
|
||||
placeholder: t(
|
||||
'nav.commandPlaceholder',
|
||||
'Search screens, applications, companies, TIN…',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
144
apps/backoffice/src/app/layouts/nav-config.ts
Normal file
144
apps/backoffice/src/app/layouts/nav-config.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconHeart,
|
||||
IconLayoutDashboard,
|
||||
IconListCheck,
|
||||
IconMapPin,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
IconRosetteDiscountCheck,
|
||||
IconRubberStamp,
|
||||
IconSettings,
|
||||
IconShieldCheck,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
} from '@tabler/icons-react';
|
||||
import type { NavSection } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Permission keys mirrored from the API's `LICENSE_PERMISSIONS`.
|
||||
*
|
||||
* Kept as literals rather than imported: the backoffice bundle must not pull
|
||||
* in server code, and these strings are a published contract — the IAM seed
|
||||
* and every `PermissionGuard([...])` already read from the same list.
|
||||
*/
|
||||
export const PERMISSIONS = {
|
||||
VIEW_APPLICATION_QUEUE: 'can:View:license-application-queue',
|
||||
VIEW_APPLICATIONS: 'can:View:license-applications',
|
||||
VIEW_LICENSE_TYPES: 'can:View:license-types',
|
||||
VIEW_PAYMENTS: 'can:View:license-payments',
|
||||
VIEW_TEMPLATES: 'can:View:license-templates',
|
||||
UPDATE_TEMPLATE: 'can:update:license-template',
|
||||
PUBLISH_TEMPLATE: 'can:publish:license-template',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The backoffice information architecture.
|
||||
*
|
||||
* Six top-level groups, none deeper than one level of nesting. `soon` marks
|
||||
* screens with no backend behind them, so a reviewer can tell at a glance what
|
||||
* actually works.
|
||||
*/
|
||||
export const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupLicensing',
|
||||
items: [
|
||||
{
|
||||
to: '/licence-review',
|
||||
label: 'nav.allApplications',
|
||||
icon: IconListCheck,
|
||||
permissions: [PERMISSIONS.VIEW_APPLICATION_QUEUE],
|
||||
},
|
||||
{
|
||||
// A disclosure, not a destination — each child deep-links the grid to
|
||||
// one type, which is a facet of the same workspace.
|
||||
label: 'nav.byType',
|
||||
icon: IconTruck,
|
||||
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
|
||||
children: [
|
||||
{ to: '/licence-review/type/FREIGHT_FORWARDER', label: 'nav.typeFreightForwarder', icon: IconTruck },
|
||||
{ to: '/licence-review/type/SHIPPING_AGENT', label: 'nav.typeShippingAgent', icon: IconShip },
|
||||
{ to: '/licence-review/type/COMBINED_SA_FF', label: 'nav.typeCombined', icon: IconFileDescription },
|
||||
{ to: '/licence-review/type/JOINT_INVESTOR', label: 'nav.typeJointInvestment', icon: IconUsers },
|
||||
{ to: '/licence-review/type/MULTIMODAL_TRANSPORT_OPERATOR', label: 'nav.typeMto', icon: IconAnchor },
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/certificate-designer',
|
||||
label: 'nav.certificateDesigner',
|
||||
icon: IconRosetteDiscountCheck,
|
||||
permissions: [PERMISSIONS.VIEW_TEMPLATES],
|
||||
},
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
||||
{
|
||||
to: '/logistics-head-dashboard',
|
||||
label: 'nav.logisticsHeadDashboard',
|
||||
icon: IconGauge,
|
||||
},
|
||||
{
|
||||
to: '/payment-config',
|
||||
label: 'nav.paymentConfig',
|
||||
icon: IconCreditCard,
|
||||
permissions: [PERMISSIONS.VIEW_PAYMENTS],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupExaminations',
|
||||
items: [
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupAdministration',
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||
{
|
||||
to: '/configuration',
|
||||
label: 'nav.configuration',
|
||||
icon: IconSettings,
|
||||
permissions: [PERMISSIONS.VIEW_LICENSE_TYPES],
|
||||
},
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||
],
|
||||
},
|
||||
// `/profile` deliberately absent: it is a property of the signed-in user,
|
||||
// not a destination in the authority's workload, and now lives in the
|
||||
// AppHeader user menu alongside sign-out.
|
||||
];
|
||||
@@ -1,7 +1,7 @@
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import { AuthBootstrap, AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { i18n } from '../i18n/config';
|
||||
@@ -27,7 +27,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<MantineThemeProvider>
|
||||
<AuthBootstrap>{children}</AuthBootstrap>
|
||||
</MantineThemeProvider>
|
||||
</I18nextProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -34,22 +34,12 @@ import { VesselRegistrationFormBuilderPage } from '../features/vessel-registrati
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { FreightForwarderLicenseQueuePage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseQueuePage';
|
||||
import { FreightForwarderLicenseReviewPage } from '../features/freight-forwarder-license/pages/FreightForwarderLicenseReviewPage';
|
||||
import { ShippingAgentLicenseQueuePage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseQueuePage';
|
||||
import { ShippingAgentLicenseReviewPage } from '../features/shipping-agent-license/pages/ShippingAgentLicenseReviewPage';
|
||||
import { CombinedLicenseQueuePage } from '../features/combined-license/pages/CombinedLicenseQueuePage';
|
||||
import { CombinedLicenseReviewPage } from '../features/combined-license/pages/CombinedLicenseReviewPage';
|
||||
import { JointInvestmentLicenseQueuePage } from '../features/joint-investment-license/pages/JointInvestmentLicenseQueuePage';
|
||||
import { JointInvestmentLicenseReviewPage } from '../features/joint-investment-license/pages/JointInvestmentLicenseReviewPage';
|
||||
import { MtoLicenseQueuePage } from '../features/mto-license/pages/MtoLicenseQueuePage';
|
||||
import { MtoLicenseReviewPage } from '../features/mto-license/pages/MtoLicenseReviewPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
|
||||
import { WaiverQueuePage } from '../features/waiver/pages/WaiverQueuePage';
|
||||
import { WaiverReviewPage } from '../features/waiver/pages/WaiverReviewPage';
|
||||
import { LogisticsHeadDashboardPage } from '../features/logistics-head/pages/LogisticsHeadDashboardPage';
|
||||
import { VesselRegistrationQueuePage } from '../features/vessel-registration/pages/VesselRegistrationQueuePage';
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
import { CertificateDesignerPage } from '../features/certificate-designer/pages/CertificateDesignerPage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -96,16 +86,23 @@ const router = createBrowserRouter([
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
{ path: 'freight-forwarder-license', element: <FreightForwarderLicenseQueuePage /> },
|
||||
{ path: 'freight-forwarder-license/:id', element: <FreightForwarderLicenseReviewPage /> },
|
||||
{ path: 'shipping-agent-license', element: <ShippingAgentLicenseQueuePage /> },
|
||||
{ path: 'shipping-agent-license/:id', element: <ShippingAgentLicenseReviewPage /> },
|
||||
{ path: 'combined-license', element: <CombinedLicenseQueuePage /> },
|
||||
{ path: 'combined-license/:id', element: <CombinedLicenseReviewPage /> },
|
||||
{ path: 'joint-investment-license', element: <JointInvestmentLicenseQueuePage /> },
|
||||
{ path: 'joint-investment-license/:id', element: <JointInvestmentLicenseReviewPage /> },
|
||||
{ path: 'mto-license', element: <MtoLicenseQueuePage /> },
|
||||
{ path: 'mto-license/:id', element: <MtoLicenseReviewPage /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||
// Deep link into the grid with the type facet pinned, so "Freight
|
||||
// Forwarder" in the nav is a filtered view rather than a page.
|
||||
{ path: 'licence-review/type/:typeCode', element: <LicenseQueuePage /> },
|
||||
{ path: 'licence-review/:id', element: <LicenseReviewPage /> },
|
||||
{ path: 'freight-forwarder-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'freight-forwarder-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'shipping-agent-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'shipping-agent-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'combined-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'combined-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'joint-investment-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'joint-investment-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'mto-license/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'waiver', element: <WaiverQueuePage /> },
|
||||
{ path: 'waiver/:id', element: <WaiverReviewPage /> },
|
||||
],
|
||||
|
||||
@@ -2,8 +2,15 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type LayoutMode = 'top' | 'sidebar';
|
||||
|
||||
/**
|
||||
* Row height in tables. Officers who work a queue all day want more rows per
|
||||
* screen; occasional users want the breathing room.
|
||||
*/
|
||||
export type Density = 'comfortable' | 'compact';
|
||||
|
||||
interface PreferencesState {
|
||||
layoutMode: LayoutMode;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
||||
@@ -11,15 +18,25 @@ const PREFERENCES_KEY = 'ema-backoffice-preferences';
|
||||
const loadPreferences = (): PreferencesState => {
|
||||
try {
|
||||
const stored = localStorage.getItem(PREFERENCES_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {}
|
||||
return { layoutMode: 'top' };
|
||||
if (stored) {
|
||||
// Merge over the defaults so a preferences blob written before a new
|
||||
// key existed does not come back with that key undefined.
|
||||
return { layoutMode: 'sidebar', density: 'comfortable', ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unavailable storage (private mode) — fall through to the default.
|
||||
}
|
||||
// The sidebar is the grouped, scannable layout; the top strip puts all
|
||||
// ~20 destinations in one horizontally-scrolling row.
|
||||
return { layoutMode: 'sidebar', density: 'comfortable' };
|
||||
};
|
||||
|
||||
const savePreferences = (state: PreferencesState) => {
|
||||
try {
|
||||
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
} catch {
|
||||
// Storage full or unavailable — the preference just will not persist.
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: PreferencesState = loadPreferences();
|
||||
@@ -32,8 +49,12 @@ const preferencesSlice = createSlice({
|
||||
state.layoutMode = action.payload;
|
||||
savePreferences(state);
|
||||
},
|
||||
setDensity(state, action: PayloadAction<Density>) {
|
||||
state.density = action.payload;
|
||||
savePreferences(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLayoutMode } = preferencesSlice.actions;
|
||||
export const { setLayoutMode, setDensity } = preferencesSlice.actions;
|
||||
export const preferencesReducer = preferencesSlice.reducer;
|
||||
|
||||
@@ -3,10 +3,32 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
import './styles.css';
|
||||
import './app/i18n/config';
|
||||
import { App } from './app/app';
|
||||
|
||||
/**
|
||||
* Branding handed to the vendored `@tria-plc/iamui` user-management module,
|
||||
* which reads it off `window` at import time. Declared here because that
|
||||
* package ships no ambient type for it.
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
__USER_MANAGEMENT_BRANDING__: {
|
||||
appName: string;
|
||||
organizationName: string;
|
||||
logoSrc: string;
|
||||
logoAlt: string;
|
||||
homePath: string;
|
||||
moduleBasePath: string;
|
||||
backToAppPath: string;
|
||||
backToAppLabel: string;
|
||||
cssVariables: Record<string, string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
document.title = 'EMA Backoffice';
|
||||
|
||||
const _favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
|
||||
@@ -5,28 +5,50 @@
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--ema-scrollbar-light: #c1c1c1;
|
||||
--ema-scrollbar-dark: #374151;
|
||||
--ema-scrollbar-track-light: #f5f8fc;
|
||||
--ema-scrollbar-track-dark: #0e1521;
|
||||
}
|
||||
/* ---------------------------------------------------------------------------
|
||||
Print — the review dossier.
|
||||
|
||||
[data-mantine-color-scheme='light'] body {
|
||||
--ema-scrollbar-thumb: var(--ema-scrollbar-light);
|
||||
--ema-scrollbar-track: var(--ema-scrollbar-track-light);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] body {
|
||||
--ema-scrollbar-thumb: var(--ema-scrollbar-dark);
|
||||
--ema-scrollbar-track: var(--ema-scrollbar-track-dark);
|
||||
}
|
||||
An officer printing a review wants the application, not the application
|
||||
plus the chrome around it. Navigation, the Decision Bar and the collapsible
|
||||
rails are all interactive surfaces with no meaning on paper, so they are
|
||||
dropped and the centre column is given the full width.
|
||||
--------------------------------------------------------------------------- */
|
||||
@media print {
|
||||
.mantine-AppShell-navbar,
|
||||
.mantine-AppShell-header,
|
||||
[role='region'][aria-label='Decision bar'],
|
||||
.mantine-Drawer-root,
|
||||
.mantine-Modal-root {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: var(--ema-scrollbar-thumb) var(--ema-scrollbar-track);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mantine-AppShell-main {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--ema-scrollbar-track); }
|
||||
::-webkit-scrollbar-thumb { background: var(--ema-scrollbar-thumb); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { opacity: 0.8; }
|
||||
/* Tabs print flattened: a printed dossier that shows one tab's worth of a
|
||||
six-tab application is missing five sixths of the record. */
|
||||
.mantine-Tabs-panel {
|
||||
display: block !important;
|
||||
}
|
||||
.mantine-Tabs-list {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Sticky positioning collapses badly across page breaks. */
|
||||
[style*='position: sticky'],
|
||||
[style*='position:sticky'] {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
/* Keep a record entry from being split across two sheets. */
|
||||
.mantine-Paper-root,
|
||||
.mantine-Card-root,
|
||||
.mantine-Table-tr {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ export default defineConfig({
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
||||
alias: {
|
||||
// The vendored user-management UI pulls in file-type → token-types,
|
||||
// which imports `node:buffer`. Point it at the browser polyfill so the
|
||||
// production build resolves it instead of failing on a Node built-in.
|
||||
'node:buffer': 'buffer',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// `buffer` expects a `global` binding that browsers do not provide.
|
||||
global: 'globalThis',
|
||||
},
|
||||
build: {
|
||||
outDir: '../../dist/apps/backoffice',
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
interface ProfileGuardProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileGuard({ children }: ProfileGuardProps) {
|
||||
const profileId = authStorage.getProfileId();
|
||||
|
||||
if (!profileId) {
|
||||
return <Navigate to="/profile-setup" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,447 +1,21 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBook2,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconShield,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BSTItem {
|
||||
key: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
modelCourse: string;
|
||||
refreshYears: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface BSTRecord {
|
||||
key: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
|
||||
certNumber: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const BST_ITEMS: BSTItem[] = [
|
||||
{
|
||||
key: 'pst',
|
||||
label: 'Personal Survival Techniques',
|
||||
shortLabel: 'PST',
|
||||
description: 'Covers lifeboat/life-raft operation, survival at sea, and distress signals.',
|
||||
modelCourse: 'IMO 1.19',
|
||||
refreshYears: 5,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'fpff',
|
||||
label: 'Fire Prevention & Fire Fighting',
|
||||
shortLabel: 'FPFF',
|
||||
description: 'Covers fire prevention, detection, and fire-fighting on board vessels.',
|
||||
modelCourse: 'IMO 1.20',
|
||||
refreshYears: 5,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'efa',
|
||||
label: 'Elementary First Aid',
|
||||
shortLabel: 'EFA',
|
||||
description: 'Basic first-aid procedures, CPR, and medical emergency response.',
|
||||
modelCourse: 'IMO 1.13',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'pssr',
|
||||
label: 'Personal Safety & Social Responsibility',
|
||||
shortLabel: 'PSSR',
|
||||
description: 'Shipboard safety culture, regulations, and working relationships.',
|
||||
modelCourse: 'IMO 1.21',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'shp',
|
||||
label: 'Sexual Harassment Prevention Training',
|
||||
shortLabel: 'SHPT',
|
||||
description: 'Awareness and prevention of harassment in the maritime workplace.',
|
||||
modelCourse: 'EMA National',
|
||||
refreshYears: 0,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_RECORDS: Record<string, BSTRecord> = {
|
||||
pst: {
|
||||
key: 'pst',
|
||||
issuer: 'Bahirdar Maritime School',
|
||||
issueDate: '2023-04-10',
|
||||
expiryDate: '2028-04-09',
|
||||
status: 'Valid',
|
||||
certNumber: 'PST-2023-BMS-0421',
|
||||
fileName: 'pst_certificate.pdf',
|
||||
},
|
||||
fpff: {
|
||||
key: 'fpff',
|
||||
issuer: 'Bahirdar Maritime School',
|
||||
issueDate: '2023-04-10',
|
||||
expiryDate: '2028-04-09',
|
||||
status: 'Valid',
|
||||
certNumber: 'FPFF-2023-BMS-0421',
|
||||
fileName: 'fpff_certificate.pdf',
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal',
|
||||
Expiring: 'orange',
|
||||
Expired: 'red',
|
||||
'Pending Verification': 'yellow',
|
||||
};
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload modal
|
||||
// ---------------------------------------------------------------------------
|
||||
function UploadModal({
|
||||
item,
|
||||
opened,
|
||||
onClose,
|
||||
onUploaded,
|
||||
}: {
|
||||
item: BSTItem | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded: (key: string, record: BSTRecord) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [certNumber, setCertNumber] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const resetRef = useRef<() => void>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null); setIssuer(''); setCertNumber(''); setIssueDate(''); setExpiryDate('');
|
||||
resetRef.current?.();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file || !issuer || !certNumber || !issueDate) {
|
||||
notify.error('Please fill all required fields and upload the certificate.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
setSubmitting(false);
|
||||
onUploaded(item!.key, {
|
||||
key: item!.key,
|
||||
issuer,
|
||||
issueDate,
|
||||
expiryDate: expiryDate || '',
|
||||
status: 'Pending Verification',
|
||||
certNumber,
|
||||
fileName: file.name,
|
||||
});
|
||||
notify.success(`${item!.shortLabel} certificate submitted for verification.`);
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={() => { reset(); onClose(); }} title={`Upload ${item?.label}`} size="md" centered>
|
||||
{item && (
|
||||
<Stack gap="sm">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">{item.description} — Model Course: <strong>{item.modelCourse}</strong></Text>
|
||||
</Alert>
|
||||
<TextInput label="Issuing Institution" placeholder="e.g. Bahirdar Maritime School" required value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} size="sm" />
|
||||
<TextInput label="Certificate Number" placeholder="e.g. PST-2024-001" required value={certNumber} onChange={(e) => setCertNumber(e.currentTarget.value)} size="sm" />
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker label="Issue Date" required value={issueDate} onChange={setIssueDate} size="sm" />
|
||||
{item.refreshYears > 0 && (
|
||||
<AmharicDatePicker label={`Expiry Date (${item.refreshYears}-yr refresh)`} value={expiryDate} onChange={setExpiryDate} size="sm" />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
||||
{file ? (
|
||||
<Card withBorder radius="sm" p="xs">
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" flex={1} truncate>{file.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setFile(null); resetRef.current?.(); }}>
|
||||
<IconTrash size={12} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={setFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={13} />} fullWidth {...props}>
|
||||
Choose File (PDF / JPG / PNG)
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</div>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={() => { reset(); onClose(); }}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting} leftSection={<IconCheck size={14} />}>Submit</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function BasicSafetyTrainingPage() {
|
||||
const [records, setRecords] = useState<Record<string, BSTRecord>>(MOCK_RECORDS);
|
||||
const [modalItem, setModalItem] = useState<BSTItem | null>(null);
|
||||
|
||||
const doneCount = BST_ITEMS.filter((i) => records[i.key]).length;
|
||||
const allDone = doneCount === BST_ITEMS.length;
|
||||
|
||||
const handleUploaded = (key: string, record: BSTRecord) => {
|
||||
setRecords((prev) => ({ ...prev, [key]: record }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Basic Safety Training</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
All seafarers must complete 5 mandatory BST certificates before joining a vessel (STCW Chapter VI).
|
||||
</Text>
|
||||
</div>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={allDone ? 'teal' : 'orange'}
|
||||
leftSection={allDone ? <IconShieldCheck size={14} /> : <IconAlertTriangle size={14} />}
|
||||
>
|
||||
{doneCount} / {BST_ITEMS.length} Complete
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Overall progress */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={700}>Overall Completion</Text>
|
||||
<Text fz="sm" c={allDone ? 'teal' : 'orange'} fw={600}>{Math.round((doneCount / BST_ITEMS.length) * 100)}%</Text>
|
||||
</Group>
|
||||
<Progress value={(doneCount / BST_ITEMS.length) * 100} color={allDone ? 'teal' : 'orange'} size="md" radius="xl" mb="md" />
|
||||
{!allDone && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={15} />} p="sm">
|
||||
<Text fz="sm">
|
||||
You need all 5 certificates to apply for a Seaman Book. Missing: <strong>{BST_ITEMS.filter((i) => !records[i.key]).map((i) => i.shortLabel).join(', ')}</strong>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{allDone && (
|
||||
<Alert variant="light" color="teal" icon={<IconShieldCheck size={15} />} p="sm">
|
||||
<Text fz="sm">
|
||||
All 5 BST training certificates are complete. You can now apply for your <strong>Seaman Book & BTC</strong> —
|
||||
the Basic Training Certificate (BTC) is issued by EMA alongside your Seaman Book after your application is approved.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Certificate cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{BST_ITEMS.map((item) => {
|
||||
const rec = records[item.key];
|
||||
const days = rec?.expiryDate ? daysUntil(rec.expiryDate) : null;
|
||||
const needsRefresh = item.refreshYears > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: rec
|
||||
? rec.status === 'Valid' ? 'var(--mantine-color-teal-4)' : 'var(--mantine-color-orange-4)'
|
||||
: 'var(--mantine-color-red-3)',
|
||||
borderStyle: rec ? 'solid' : 'dashed',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant={rec ? 'filled' : 'light'}
|
||||
color={rec ? STATUS_COLOR[rec.status] : 'red'}
|
||||
>
|
||||
{rec ? <IconShieldCheck size={20} /> : <IconShield size={20} />}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{item.shortLabel}</Text>
|
||||
<Text fz="xs" c="dimmed">{item.modelCourse}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{rec ? (
|
||||
<Badge color={STATUS_COLOR[rec.status]} variant="light" size="sm">{rec.status}</Badge>
|
||||
) : (
|
||||
<Badge color="red" variant="light" size="sm">Missing</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{item.label}</Text>
|
||||
|
||||
{rec ? (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Certificate No.</Text>
|
||||
<Text fz="xs" fw={600}>{rec.certNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issuer</Text>
|
||||
<Text fz="xs" ta="right" maw={140} truncate>{rec.issuer}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs">{formatDate(rec.issueDate)}</Text>
|
||||
</Group>
|
||||
{needsRefresh && rec.expiryDate && (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={600} c={days !== null && days <= 180 ? 'orange' : undefined}>
|
||||
{formatDate(rec.expiryDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
{days !== null && (
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, (days / (item.refreshYears * 365)) * 100))}
|
||||
color={days <= 90 ? 'red' : days <= 180 ? 'orange' : 'teal'}
|
||||
size="xs"
|
||||
radius="xl"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{needsRefresh && (
|
||||
<Badge size="xs" variant="dot" color="blue" mt={2}>
|
||||
<IconRefresh size={10} /> {item.refreshYears}-year refresh required
|
||||
</Badge>
|
||||
)}
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconDownload size={12} />} flex={1}>
|
||||
Download
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" color="orange" leftSection={<IconUpload size={12} />} flex={1} onClick={() => setModalItem(item)}>
|
||||
Update
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{item.description}</Text>
|
||||
{needsRefresh && (
|
||||
<Badge size="xs" variant="dot" color="blue">
|
||||
Requires {item.refreshYears}-year refresh
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconUpload size={13} />}
|
||||
onClick={() => setModalItem(item)}
|
||||
fullWidth
|
||||
mt="xs"
|
||||
>
|
||||
Upload Certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Info */}
|
||||
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconBook2 size={18} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="sm">About Basic Safety Training (STCW VI/1)</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" lh={1.6}>
|
||||
Basic Safety Training is mandatory for <strong>all seafarers</strong> regardless of department (Deck, Engine, or Catering).
|
||||
PST and FPFF certificates require evidence of maintained competence every <strong>5 years</strong>.
|
||||
EFA and PSSR do not have a mandatory 5-year repeat under STCW.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz="xs" c="dimmed" lh={1.6}>
|
||||
Certificates must be from <strong>EMA-approved training institutions</strong> (e.g. Bahirdar Maritime School, Babugaya Maritime School).
|
||||
EMA officers will verify authenticity before approving your Seaman Book application.
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Upload modal */}
|
||||
<UploadModal
|
||||
item={modalItem}
|
||||
opened={!!modalItem}
|
||||
onClose={() => setModalItem(null)}
|
||||
onUploaded={handleUploaded}
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Basic Safety Training"
|
||||
description="BST records are not connected to the backend yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default BasicSafetyTrainingPage;
|
||||
|
||||
@@ -1,266 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useErrorHandler } from '@ema-platform/ui';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconInfoCircle,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_COC_APPS = [
|
||||
{
|
||||
id: 'COC-APP-2025-001',
|
||||
type: 'CoC — STCW II/1 Officer in Charge of Navigational Watch',
|
||||
submitted: '2025-03-10',
|
||||
examDate: '2025-04-15',
|
||||
examVenue: 'EMA HQ — Addis Ababa',
|
||||
status: 'Examination Scheduled',
|
||||
statusColor: 'indigo',
|
||||
statusNote: 'TRB inspected and approved by EMA officer. Attend your scheduled examination.',
|
||||
},
|
||||
{
|
||||
id: 'COC-APP-2025-005',
|
||||
type: 'CoC — STCW II/5 Able Seafarer Deck (AB)',
|
||||
submitted: '2025-05-01',
|
||||
examDate: null,
|
||||
examVenue: null,
|
||||
status: 'TRB Inspection',
|
||||
statusColor: 'yellow',
|
||||
statusNote: 'Your TRB is being physically inspected by an EMA officer. You may be contacted to bring the original document.',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_CERTIFICATES = [
|
||||
{
|
||||
id: 'COC-2023-0042',
|
||||
type: 'CoC — STCW II/1',
|
||||
issued: '2023-06-20',
|
||||
expiry: '2028-06-20',
|
||||
status: 'Valid',
|
||||
statusColor: 'teal',
|
||||
},
|
||||
];
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
async function generateCertificate(profileId: string): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const res = await fetch(
|
||||
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const profileId = authStorage.getProfileId() ?? '';
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const openPreview = async (profileId: string, title: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const blob = await generateCertificate(profileId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setPreviewTitle(title);
|
||||
setPreviewUrl(url);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (profileId: string, title: string) => {
|
||||
try {
|
||||
const blob = await generateCertificate(profileId);
|
||||
downloadBlob(blob, `certificate-${Date.now()}.pdf`);
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Downloaded',
|
||||
message: 'Certificate PDF downloaded successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Certificates (CoC / CoP)</Title>
|
||||
<Text fz="sm" c="dimmed">Certificate of Competency and Certificate of Proficiency under STCW</Text>
|
||||
</div>
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={15} />}
|
||||
rightSection={<IconArrowRight size={15} />}
|
||||
onClick={() => navigate('/certificates/apply')}
|
||||
>
|
||||
Apply for CoC / CoP
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Info banner */}
|
||||
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconShieldCheck size={24} /></ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} fz="sm">What is a CoC / CoP?</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
{[
|
||||
{ icon: IconBook2, color: 'blue', title: 'Certificate of Competency (CoC)', desc: 'Authorizes the holder to serve as an officer or master on board a ship under STCW.' },
|
||||
{ icon: IconCertificate, color: 'teal', title: 'Certificate of Proficiency (CoP)', desc: 'Certifies completion of specific STCW training for specialized duties on board.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Validity', desc: 'CoC/CoP certificates are valid for 5 years and must be revalidated before expiry.' },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
||||
<Text fz="xs" fw={700}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Active applications */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Applications</Text>
|
||||
{MOCK_COC_APPS.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active CoC/CoP applications. Click "Apply for CoC / CoP" to start.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table highlightOnHover fz="sm" verticalSpacing="sm">
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
{['App ID', 'Certificate Type', 'Submitted', 'Exam Date / Status Note', 'Status', ''].map((h) => (
|
||||
<Table.Th key={h} style={{ fontSize: rem(11), textTransform: 'uppercase', color: 'var(--mantine-color-dimmed)' }}>{h}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{MOCK_COC_APPS.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td><Text fz="xs" fw={600} c="blue.7">{app.id}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs" fw={500} maw={220} style={{ lineHeight: 1.3 }}>{app.type}</Text></Table.Td>
|
||||
<Table.Td><Text fz="xs">{app.submitted}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{app.examDate
|
||||
? <><Text fz="xs" fw={500}>{app.examDate}</Text><Text fz="xs" c="dimmed">{app.examVenue}</Text></>
|
||||
: <Text fz="xs" c="dimmed" maw={200} lh={1.3}>{app.statusNote}</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={app.statusColor} variant="light" size="sm">{app.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" c="blue" style={{ cursor: 'pointer' }}>Details</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issued certificates */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Certificates</Text>
|
||||
{MOCK_CERTIFICATES.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No certificates issued yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{MOCK_CERTIFICATES.map((cert) => (
|
||||
<Card key={cert.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShieldCheck size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{cert.type}</Text>
|
||||
<Text fz="xs" c="dimmed">{cert.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={cert.statusColor} variant="light">{cert.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{cert.issued}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{cert.expiry}</Text></div>
|
||||
</SimpleGrid>
|
||||
<Group mt="sm" gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={loading ? <Loader size={12} /> : <IconEye size={12} />} onClick={() => openPreview(profileId, cert.type)}>View</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />} onClick={() => handleDownload(profileId, cert.type)}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
|
||||
size="95vw"
|
||||
radius="lg"
|
||||
fullScreen
|
||||
>
|
||||
<iframe
|
||||
src={previewUrl ?? ''}
|
||||
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
|
||||
title={previewTitle}
|
||||
/>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="My certificates"
|
||||
description="Seafarer certificates are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default CertificatesPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,461 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +1,583 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
rem,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBook2,
|
||||
IconChevronRight,
|
||||
IconAlertTriangle,
|
||||
IconCertificate,
|
||||
IconClipboardList,
|
||||
IconFileCheck,
|
||||
IconHeart,
|
||||
IconLifebuoy,
|
||||
IconShip,
|
||||
IconClockHour4,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconShieldCheck,
|
||||
IconUserPlus,
|
||||
IconBell,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||
import {
|
||||
APPLICANT_ACTION_STATUSES,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
localized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock profile completeness — replace with real store/API data
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROFILE_STEPS = [
|
||||
{ label: 'Personal Information', done: true },
|
||||
{ label: 'Document Upload', done: true },
|
||||
{ label: 'Medical Certificate', done: false },
|
||||
{ label: 'Basic Safety Training', done: false },
|
||||
{ label: 'Seaman Book', done: false },
|
||||
];
|
||||
/**
|
||||
* The applicant's home screen.
|
||||
*
|
||||
* Ordered by what the applicant needs from it: first anything blocked on them,
|
||||
* then a read of where their applications stand, then the licence catalogue,
|
||||
* then the licences they already hold. Every figure is the signed-in user's
|
||||
* own data — there are no illustrative numbers on this page.
|
||||
*/
|
||||
|
||||
const BST_ITEMS = [
|
||||
{ label: 'Personal Survival Techniques (PST)', done: true, expiry: '2028-04-10' },
|
||||
{ label: 'Fire Prevention & Fire Fighting (FPFF)', done: true, expiry: '2028-04-10' },
|
||||
{ label: 'Elementary First Aid (EFA)', done: false, expiry: null },
|
||||
{ label: 'Personal Safety & Social Responsibility (PSSR)', done: false, expiry: null },
|
||||
{ label: 'Sexual Harassment Prevention', done: false, expiry: null },
|
||||
];
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
|
||||
const ALERTS = [
|
||||
{ id: 1, type: 'warning', message: 'Medical certificate expires in 45 days. Please renew before it lapses.', route: '/medical-certificate' },
|
||||
{ id: 2, type: 'info', message: '3 Basic Safety Training certificates are missing. Complete them to apply for a Seaman Book.', route: '/basic-safety-training' },
|
||||
];
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
function formatMoney(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return 'No fee';
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value)) return 'No fee';
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useMantineTheme();
|
||||
const displayName = useSelector(
|
||||
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
|
||||
state.auth.user?.name?.en || state.auth.user?.username || '',
|
||||
);
|
||||
|
||||
const doneSteps = PROFILE_STEPS.filter((s) => s.done).length;
|
||||
const completeness = Math.round((doneSteps / PROFILE_STEPS.length) * 100);
|
||||
const bstDone = BST_ITEMS.filter((b) => b.done).length;
|
||||
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
||||
useGetCertificateUrlMutation();
|
||||
|
||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
||||
|
||||
const needsMe = items.filter((a) =>
|
||||
APPLICANT_ACTION_STATUSES.includes(a.status),
|
||||
);
|
||||
const inProgress = items.filter(
|
||||
(a) => !TERMINAL_STATUSES.includes(a.status) && a.status !== 'DRAFT',
|
||||
);
|
||||
const activeLicenses = heldLicenses.filter((l) => l.status === 'ACTIVE');
|
||||
const expiringSoon = activeLicenses.filter((l) => {
|
||||
const days = daysUntil(l.expiryDate);
|
||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
||||
});
|
||||
|
||||
async function downloadCertificate(license: IssuedLicense) {
|
||||
const result = await getCertificateUrl(license.id).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p="xl"
|
||||
style={{ background: theme.other.heroGradient as string, overflow: 'hidden' }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Stack gap="md" maw={560}>
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={26}>
|
||||
Welcome to the EMA Seafarer Portal
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)' }} lh={1.55}>
|
||||
Manage your seafarer profile, track certificates, apply for your Seaman Book
|
||||
and monitor your maritime credentials — all in one place.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Center
|
||||
visibleFrom="sm"
|
||||
w={120}
|
||||
h={120}
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.15)', flexShrink: 0 }}
|
||||
<Container size="xl" py="lg">
|
||||
<Stack gap="xl">
|
||||
<Hero
|
||||
displayName={displayName}
|
||||
applicationCount={items.length}
|
||||
licenseCount={activeLicenses.length}
|
||||
/>
|
||||
|
||||
{/* A prompt, not a gate — dismissible and it never blocks the page. */}
|
||||
<ProfileCompletionNudge />
|
||||
|
||||
{needsMe.length > 0 && (
|
||||
<ActionRequired applications={needsMe} navigate={navigate} />
|
||||
)}
|
||||
|
||||
{expiringSoon.length > 0 && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<IconClockHour4 size={18} />}
|
||||
title={
|
||||
expiringSoon.length === 1
|
||||
? 'A licence is expiring soon'
|
||||
: `${expiringSoon.length} licences are expiring soon`
|
||||
}
|
||||
>
|
||||
<IconShip size={62} color="white" stroke={1.4} />
|
||||
</Center>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Text size="sm">
|
||||
{expiringSoon
|
||||
.map(
|
||||
(l) =>
|
||||
`${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
{ALERTS.map((alert) => (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
variant="light"
|
||||
color={alert.type === 'warning' ? 'orange' : 'blue'}
|
||||
icon={alert.type === 'warning' ? <IconAlertCircle size={17} /> : <IconBell size={17} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(alert.route)}
|
||||
<StatRow
|
||||
inProgress={inProgress.length}
|
||||
needsMe={needsMe.length}
|
||||
activeLicenses={activeLicenses.length}
|
||||
expiringSoon={expiringSoon.length}
|
||||
/>
|
||||
|
||||
<Section
|
||||
title="Apply for a licence"
|
||||
description="Choose the licence that matches the service your company provides."
|
||||
>
|
||||
{alert.message}
|
||||
</Alert>
|
||||
))}
|
||||
<LicenseCatalogue />
|
||||
</Section>
|
||||
|
||||
{/* Status cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatusCard label="Profile Complete" value={`${completeness}%`} icon={IconUserPlus} color="blue" />
|
||||
<StatusCard label="BST Certificates" value={`${bstDone} / ${BST_ITEMS.length}`} icon={IconShieldCheck} color="teal" />
|
||||
<StatusCard label="Medical Status" value="Expiring" icon={IconHeart} color="orange" />
|
||||
<StatusCard label="Seaman Book" value="Not Applied" icon={IconBook2} color="gray" />
|
||||
</SimpleGrid>
|
||||
<Section
|
||||
title="My applications"
|
||||
action={
|
||||
items.length > 0 ? (
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => navigate('/licensing/applications')}
|
||||
>
|
||||
View all
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<EmptyCard message="You have not filed any applications yet. Pick a licence above to get started." />
|
||||
) : (
|
||||
<ApplicationTable
|
||||
applications={items.slice(0, 6)}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||
{/* Profile completeness */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700}>Registration Checklist</Text>
|
||||
<Badge variant="light" color={completeness === 100 ? 'teal' : 'blue'}>
|
||||
{completeness}% complete
|
||||
</Badge>
|
||||
</Group>
|
||||
<Progress value={completeness} color={completeness === 100 ? 'teal' : 'blue'} mb="md" radius="xl" size="sm" />
|
||||
<Stack gap="xs">
|
||||
{PROFILE_STEPS.map((step) => (
|
||||
<Group key={step.label} gap="xs">
|
||||
<ThemeIcon
|
||||
size={20}
|
||||
radius="xl"
|
||||
variant={step.done ? 'filled' : 'light'}
|
||||
color={step.done ? 'teal' : 'gray'}
|
||||
>
|
||||
<IconFileCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={step.done ? undefined : 'dimmed'} td={step.done ? undefined : undefined}>
|
||||
{step.label}
|
||||
</Text>
|
||||
{step.done && <Badge size="xs" color="teal" variant="light" ml="auto">Done</Badge>}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* BST tracker */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700}>Basic Safety Training</Text>
|
||||
<Badge variant="light" color={bstDone === BST_ITEMS.length ? 'teal' : 'orange'}>
|
||||
{bstDone} / {BST_ITEMS.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{BST_ITEMS.map((item) => (
|
||||
<Group key={item.label} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size={20} radius="xl" variant={item.done ? 'filled' : 'light'} color={item.done ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
|
||||
<IconShieldCheck size={12} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" flex={1} c={item.done ? undefined : 'dimmed'} style={{ lineHeight: 1.3 }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.done && item.expiry && (
|
||||
<Text fz="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>exp {item.expiry}</Text>
|
||||
)}
|
||||
{!item.done && (
|
||||
<Badge size="xs" color="red" variant="light">Missing</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Quick actions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">Quick Actions</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm">
|
||||
<QuickAction icon={IconUserPlus} color="emaPrimary" label="New Seafarer Registration" sub="Register a new seafarer profile" onClick={() => navigate('/seafarer-registration')} />
|
||||
<QuickAction icon={IconBook2} color="blue" label="Apply for Seaman Book" sub="Submit your Seaman Book application" onClick={() => navigate('/seaman-book')} />
|
||||
<QuickAction icon={IconShieldCheck} color="teal" label="Certificates (CoC / CoP)" sub="Apply for STCW certificates" onClick={() => navigate('/certificates')} />
|
||||
<QuickAction icon={IconHeart} color="red" label="Medical Certificate" sub="Upload or renew your medical certificate" onClick={() => navigate('/documents')} />
|
||||
<QuickAction icon={IconClipboardList} color="violet" label="Document Vault" sub="Manage all your uploaded documents" onClick={() => navigate('/documents')} />
|
||||
<QuickAction icon={IconLifebuoy} color="orange" label="Help & Support" sub="Get assistance from EMA staff" onClick={() => navigate('/support')} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
{heldLicenses.length > 0 && (
|
||||
<Section title="My licences">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{heldLicenses.map((license) => (
|
||||
<LicenseCard
|
||||
key={license.id}
|
||||
license={license}
|
||||
isDownloading={isDownloading}
|
||||
onDownload={() => downloadCertificate(license)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Section>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ label, value, icon: Icon, color }: { label: string; value: string; icon: Icon; color: string }) {
|
||||
// --------------------------------------------------------------- components
|
||||
|
||||
function Hero({
|
||||
displayName,
|
||||
applicationCount,
|
||||
licenseCount,
|
||||
}: {
|
||||
displayName: string;
|
||||
applicationCount: number;
|
||||
licenseCount: number;
|
||||
}) {
|
||||
const summary =
|
||||
applicationCount === 0 && licenseCount === 0
|
||||
? 'Apply for a maritime or logistics licence and track it through to issue.'
|
||||
: `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz="xl" fw={700} lh={1}>{value}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={4}>{label}</Text>
|
||||
</div>
|
||||
<ThemeIcon variant="light" color={color} size={46} radius="md">
|
||||
<Icon size={22} stroke={1.6} />
|
||||
<Paper
|
||||
radius="lg"
|
||||
p="xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--mantine-color-emaPrimary-7) 0%, var(--mantine-color-emaPrimary-9) 55%, var(--mantine-color-emaTeal-8) 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" style={{ opacity: 0.85 }}>
|
||||
Ethiopian Maritime Authority
|
||||
</Text>
|
||||
<Title order={2} mt={4} c="white">
|
||||
{displayName ? `Welcome back, ${displayName}` : 'Welcome back'}
|
||||
</Title>
|
||||
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
|
||||
{summary}
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
c="white"
|
||||
visibleFrom="sm"
|
||||
>
|
||||
<IconShieldCheck size={44} stroke={1.3} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRequired({
|
||||
applications,
|
||||
navigate,
|
||||
}: {
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" bg="orange.0">
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
||||
<IconAlertTriangle size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Waiting on you
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{applications.map((app) => {
|
||||
const detail = detailFor(app);
|
||||
return (
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder bg="white">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={STATUS_COLORS[app.status]}
|
||||
radius="md"
|
||||
>
|
||||
{app.status === 'PAYMENT_PENDING' ? (
|
||||
<IconCreditCard size={16} />
|
||||
) : (
|
||||
<IconClipboardList size={16} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{detail.message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
color={detail.color}
|
||||
onClick={() => navigate(detail.path)}
|
||||
>
|
||||
{detail.cta}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the applicant has to do next, and where that happens. */
|
||||
function detailFor(app: LicenseApplication): {
|
||||
message: string;
|
||||
cta: string;
|
||||
color: string;
|
||||
path: string;
|
||||
} {
|
||||
const typeKey = app.licenseType?.key;
|
||||
const wizard = typeKey
|
||||
? `/licensing/${typeKey}/applications/${app.id}`
|
||||
: '/licensing/applications';
|
||||
|
||||
switch (app.status) {
|
||||
case 'RESUBMIT_REQUIRED':
|
||||
return {
|
||||
message: 'A reviewer asked for corrections before this can proceed.',
|
||||
cta: 'Fix now',
|
||||
color: 'orange',
|
||||
path: wizard,
|
||||
};
|
||||
case 'PAYMENT_PENDING':
|
||||
return {
|
||||
message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`,
|
||||
cta: 'Pay now',
|
||||
color: 'yellow',
|
||||
path: '/licensing/applications',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: 'This application is still a draft and has not been filed.',
|
||||
cta: 'Continue',
|
||||
color: 'blue',
|
||||
path: wizard,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
inProgress,
|
||||
needsMe,
|
||||
activeLicenses,
|
||||
expiringSoon,
|
||||
}: {
|
||||
inProgress: number;
|
||||
needsMe: number;
|
||||
activeLicenses: number;
|
||||
expiringSoon: number;
|
||||
}) {
|
||||
const stats = [
|
||||
{ label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' },
|
||||
{ label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' },
|
||||
{ label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' },
|
||||
{ label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' },
|
||||
];
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.label} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Text fz={30} fw={700} lh={1.2} mt={4}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon variant="light" color={stat.color} radius="md" size="lg">
|
||||
<stat.icon size={18} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-end" mb="sm">
|
||||
<Box>
|
||||
<Title order={4}>{title}</Title>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{action}
|
||||
</Group>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ApplicationTable({
|
||||
applications,
|
||||
navigate,
|
||||
}: {
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Application</Table.Th>
|
||||
<Table.Th>Licence</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={180}>Progress</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{applications.map((app) => (
|
||||
<Table.Tr
|
||||
key={app.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
app.licenseType?.key
|
||||
? `/licensing/${app.licenseType.key}/applications/${app.id}`
|
||||
: '/licensing/applications',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.companyName ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{localized(app.licenseType?.name) || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const days = daysUntil(license.expiryDate);
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(license.licenseType?.name) || 'Licence'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? 'Expired' : license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{expired ? 'Expired on' : 'Valid until'}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatDate(license.expiryDate)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label="Download certificate">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="lg"
|
||||
loading={isDownloading}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({
|
||||
icon: ActionIconCmp,
|
||||
color,
|
||||
label,
|
||||
sub,
|
||||
onClick,
|
||||
}: {
|
||||
icon: Icon;
|
||||
color: string;
|
||||
label: string;
|
||||
sub: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
function EmptyCard({ message }: { message: string }) {
|
||||
return (
|
||||
<UnstyledButton onClick={onClick} style={{ width: '100%' }}>
|
||||
<Card padding="sm" radius="md" bg="var(--mantine-color-default-hover)" style={{ height: '100%' }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={42} radius="md" style={{ flexShrink: 0 }}>
|
||||
<ActionIconCmp size={20} />
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Center>
|
||||
<Stack gap={6} align="center">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconFileText size={18} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="sm" fw={600} lh={1.3}>{label}</Text>
|
||||
<Text fz="xs" c="dimmed" mt={2} lh={1.3}>{sub}</Text>
|
||||
</div>
|
||||
<IconChevronRight size={16} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,487 +1,21 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBook2,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCloudDownload,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileCheck,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconPhoto,
|
||||
IconSchool,
|
||||
IconSearch,
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EMA-issued / system-generated documents
|
||||
// ---------------------------------------------------------------------------
|
||||
interface IssuedDoc {
|
||||
key: string;
|
||||
label: string;
|
||||
category: 'certificate' | 'book';
|
||||
color: string;
|
||||
icon: typeof IconBook2;
|
||||
issuedDate: string;
|
||||
expiryDate: string | null;
|
||||
status: 'issued' | 'pending' | 'expired';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const ISSUED_DOCS: IssuedDoc[] = [
|
||||
{
|
||||
key: 'seaman-book',
|
||||
label: 'Seaman Book',
|
||||
category: 'book',
|
||||
color: 'blue',
|
||||
icon: IconBook2,
|
||||
issuedDate: '2024-06-01',
|
||||
expiryDate: '2029-06-01',
|
||||
status: 'issued',
|
||||
description: 'Official EMA-issued seafarer identification document. Valid for 5 years.',
|
||||
},
|
||||
{
|
||||
key: 'btc',
|
||||
label: 'Basic Training Certificate (BTC)',
|
||||
category: 'certificate',
|
||||
color: 'teal',
|
||||
icon: IconCertificate,
|
||||
issuedDate: '2024-06-01',
|
||||
expiryDate: '2029-06-01',
|
||||
status: 'issued',
|
||||
description: 'EMA-issued BTC certifying completion of all 5 basic safety training courses.',
|
||||
},
|
||||
{
|
||||
key: 'medical',
|
||||
label: 'Medical Fitness Certificate',
|
||||
category: 'certificate',
|
||||
color: 'pink',
|
||||
icon: IconHeart,
|
||||
issuedDate: '2024-03-20',
|
||||
expiryDate: '2026-03-20',
|
||||
status: 'issued',
|
||||
description: 'Medical fitness certificate from an EMA-approved medical centre.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BTC sub-certificates (the 5 training certs that qualify you for BTC)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BtcCert {
|
||||
key: string;
|
||||
short: string;
|
||||
label: string;
|
||||
certNumber: string;
|
||||
issuer: string;
|
||||
issueDate: string;
|
||||
expiryDate: string | null;
|
||||
}
|
||||
|
||||
const BTC_CERTS: BtcCert[] = [
|
||||
{ key: 'pst', short: 'PST', label: 'Personal Survival Techniques', certNumber: 'PST-2024-001', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-15', expiryDate: '2029-01-15' },
|
||||
{ key: 'fpff', short: 'FPFF', label: 'Fire Prevention & Fire Fighting', certNumber: 'FPFF-2024-002', issuer: 'Bahirdar Maritime School', issueDate: '2024-01-16', expiryDate: '2029-01-16' },
|
||||
{ key: 'efa', short: 'EFA', label: 'Elementary First Aid', certNumber: 'EFA-2024-003', issuer: 'EMA Training Centre', issueDate: '2024-02-01', expiryDate: null },
|
||||
{ key: 'pssr', short: 'PSSR', label: 'Personal Safety & Social Responsibility', certNumber: 'PSSR-2024-004', issuer: 'EMA Training Centre', issueDate: '2024-02-02', expiryDate: null },
|
||||
{ key: 'shpt', short: 'SHPT', label: 'Sexual Harassment Prevention Training', certNumber: 'SHPT-2024-005', issuer: 'EMA Training Centre', issueDate: '2024-02-03', expiryDate: null },
|
||||
];
|
||||
|
||||
const STATUS_COLOR = { issued: 'teal', pending: 'yellow', expired: 'red' } as const;
|
||||
const STATUS_LABEL = { issued: 'Issued', pending: 'Pending', expired: 'Expired' } as const;
|
||||
|
||||
// Demo PDF for preview
|
||||
const DEMO_PDF =
|
||||
'data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjIwCiUlRU9G';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uploaded documents (user-provided supporting docs)
|
||||
// ---------------------------------------------------------------------------
|
||||
interface DocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
icon: typeof IconId;
|
||||
category: 'Identity' | 'Education' | 'Photo';
|
||||
accept: string;
|
||||
}
|
||||
|
||||
interface UploadedDoc {
|
||||
key: string;
|
||||
file: File;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DOC_SLOTS: DocSlot[] = [
|
||||
{ key: 'nationalId', label: 'National ID / Fayda', description: 'Front and back of your national identity card', required: true, icon: IconId, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'passport', label: 'Passport', description: 'Bio-data page of a valid passport', required: false, icon: IconFileDescription, category: 'Identity', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'photo', label: 'Passport Size Photo', description: 'Recent photo, white background, 3.5×4.5 cm', required: true, icon: IconPhoto, category: 'Photo', accept: 'image/jpeg,image/png' },
|
||||
{ key: 'graduation', label: 'Graduation Certificate', description: 'Highest academic qualification certificate', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
{ key: 'transcript', label: 'Academic Transcript', description: 'Official academic transcript from institution', required: false, icon: IconSchool, category: 'Education', accept: 'application/pdf,image/jpeg,image/png' },
|
||||
];
|
||||
|
||||
const UPLOAD_CATEGORIES = ['All', 'Identity', 'Photo', 'Education'] as const;
|
||||
type UploadCategory = typeof UPLOAD_CATEGORIES[number];
|
||||
|
||||
const CAT_COLOR: Record<string, string> = { Identity: 'blue', Photo: 'violet', Education: 'teal' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered invented figures/records that were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function DocumentVaultPage() {
|
||||
const [docs, setDocs] = useState<Record<string, UploadedDoc | null>>(() =>
|
||||
Object.fromEntries(DOC_SLOTS.map((s) => [s.key, null]))
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<UploadCategory>('All');
|
||||
const [previewDoc, setPreviewDoc] = useState<{ name: string; url: string; isImage: boolean } | null>(null);
|
||||
const resetRefs = useRef<Record<string, (() => void) | null>>({});
|
||||
|
||||
const handleUpload = (key: string) => (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) { notify.error('File exceeds 5MB limit.'); return; }
|
||||
const url = URL.createObjectURL(file);
|
||||
setDocs((prev) => ({ ...prev, [key]: { key, file, uploadedAt: new Date().toLocaleDateString('en-GB'), url } }));
|
||||
notify.success(`${file.name} uploaded.`);
|
||||
};
|
||||
|
||||
const handleRemove = (key: string) => {
|
||||
const doc = docs[key];
|
||||
if (doc) URL.revokeObjectURL(doc.url);
|
||||
setDocs((prev) => ({ ...prev, [key]: null }));
|
||||
resetRefs.current[key]?.();
|
||||
notify.info('Document removed.');
|
||||
};
|
||||
|
||||
const filtered = DOC_SLOTS.filter((slot) => {
|
||||
const matchCat = category === 'All' || slot.category === category;
|
||||
const matchSearch = !search || slot.label.toLowerCase().includes(search.toLowerCase());
|
||||
return matchCat && matchSearch;
|
||||
});
|
||||
|
||||
const uploadedCount = Object.values(docs).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>My Documents</Title>
|
||||
<Text fz="sm" c="dimmed">All your EMA-issued certificates and uploaded supporting documents</Text>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="issued" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="issued" leftSection={<IconFileCheck size={16} />}>
|
||||
Certificates & Issued Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="uploaded" leftSection={<IconUpload size={16} />}>
|
||||
Uploaded Documents
|
||||
<Badge size="xs" variant="light" color="blue" ml={6}>{uploadedCount} / {DOC_SLOTS.length}</Badge>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ── Certificates & Issued Documents ──────────────────────────── */}
|
||||
<Tabs.Panel value="issued">
|
||||
<Stack gap="xl">
|
||||
{/* EMA-issued documents */}
|
||||
<div>
|
||||
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="gray.6">EMA Issued Documents</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{ISSUED_DOCS.map((doc) => {
|
||||
const DocIcon = doc.icon;
|
||||
return (
|
||||
<Card key={doc.key} withBorder radius="lg" p="md"
|
||||
style={{
|
||||
borderColor: doc.status === 'issued'
|
||||
? `var(--mantine-color-${doc.color}-3)`
|
||||
: doc.status === 'expired'
|
||||
? 'var(--mantine-color-red-3)'
|
||||
: 'var(--mantine-color-yellow-3)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<ThemeIcon size="xl" variant="light" color={doc.color} radius="lg">
|
||||
<DocIcon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={700} fz="sm" lh={1.2}>{doc.label}</Text>
|
||||
<Badge size="xs" variant="light" color={STATUS_COLOR[doc.status]} mt={3}>
|
||||
{STATUS_LABEL[doc.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" mb="sm" lh={1.4}>{doc.description}</Text>
|
||||
<Stack gap={3} mb="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs" fw={500}>{doc.issuedDate}</Text>
|
||||
</Group>
|
||||
{doc.expiryDate && (
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={500}>{doc.expiryDate}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Divider mb="sm" />
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" color={doc.color} leftSection={<IconEye size={13} />} style={{ flex: 1 }}
|
||||
onClick={() => setPreviewDoc({ name: doc.label, url: DEMO_PDF, isImage: false })}>
|
||||
View
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconCloudDownload size={13} />}
|
||||
component="a" href={DEMO_PDF} download={`${doc.label}.pdf`}>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
{/* BTC training certs — all 5 listed */}
|
||||
<div>
|
||||
<Group gap="sm" mb="sm" align="center">
|
||||
<ThemeIcon size="md" variant="light" color="teal" radius="md">
|
||||
<IconShieldCheck size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="gray.6">BTC Training Certificates (5/5)</Text>
|
||||
<Text fz="xs" c="dimmed">Submitted training certificates that qualified you for the BTC</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{BTC_CERTS.map((cert) => (
|
||||
<Card key={cert.key} withBorder radius="md" p="md"
|
||||
style={{ borderColor: 'var(--mantine-color-teal-3)' }}>
|
||||
<Group gap="sm" mb="xs" wrap="nowrap">
|
||||
<ThemeIcon size="lg" variant="light" color="teal" radius="md">
|
||||
<IconShieldCheck size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} align="center">
|
||||
<Text fw={700} fz="sm">{cert.short}</Text>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-teal-6)" />
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{cert.label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap={3} mb="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Cert No.</Text>
|
||||
<Text fz="xs" fw={500}>{cert.certNumber}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issuer</Text>
|
||||
<Text fz="xs" fw={500} style={{ textAlign: 'right', maxWidth: rem(140) }}>{cert.issuer}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Issued</Text>
|
||||
<Text fz="xs" fw={500}>{cert.issueDate}</Text>
|
||||
</Group>
|
||||
{cert.expiryDate && (
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed">Expires</Text>
|
||||
<Text fz="xs" fw={500}>{cert.expiryDate}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<IconEye size={13} />} fullWidth
|
||||
onClick={() => setPreviewDoc({ name: `${cert.short} Certificate`, url: DEMO_PDF, isImage: false })}>
|
||||
View Certificate
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ── Uploaded supporting documents ─────────────────────────────── */}
|
||||
<Tabs.Panel value="uploaded">
|
||||
<Stack gap="md">
|
||||
{/* Category summary */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
|
||||
{(['Identity', 'Photo', 'Education'] as const).map((cat) => {
|
||||
const slots = DOC_SLOTS.filter((s) => s.category === cat);
|
||||
const done = slots.filter((s) => docs[s.key]).length;
|
||||
return (
|
||||
<Card key={cat} withBorder radius="md" p="sm" style={{ cursor: 'pointer' }} onClick={() => setCategory(cat)}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={CAT_COLOR[cat]} size={36} radius="md">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">{cat}</Text>
|
||||
<Text fz="sm" fw={700}>{done}/{slots.length}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search documents…"
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
style={{ minWidth: rem(220) }}
|
||||
rightSection={search ? (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => setSearch('')}><IconX size={13} /></ActionIcon>
|
||||
) : null}
|
||||
/>
|
||||
<Group gap={6}>
|
||||
{UPLOAD_CATEGORIES.map((cat) => (
|
||||
<Button key={cat} size="xs"
|
||||
variant={category === cat ? 'filled' : 'light'}
|
||||
color={cat === 'All' ? 'gray' : CAT_COLOR[cat] ?? 'gray'}
|
||||
onClick={() => setCategory(cat)}>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Document cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{filtered.map((slot) => {
|
||||
const doc = docs[slot.key];
|
||||
const SlotIcon = slot.icon;
|
||||
const resetRef = { current: null as (() => void) | null };
|
||||
return (
|
||||
<Card key={slot.key} withBorder radius="md" p="md"
|
||||
style={{
|
||||
borderStyle: doc ? 'solid' : 'dashed',
|
||||
borderColor: doc
|
||||
? 'var(--mantine-color-teal-5)'
|
||||
: slot.required
|
||||
? 'var(--mantine-color-orange-4)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" mb="sm" wrap="nowrap">
|
||||
<Box style={{
|
||||
width: rem(40), height: rem(40), borderRadius: rem(8), flexShrink: 0,
|
||||
background: doc ? 'var(--mantine-color-teal-light)' : 'var(--mantine-color-blue-light)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<SlotIcon size={20} color={doc ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-blue-6)'} stroke={1.5} />
|
||||
</Box>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={4}>
|
||||
<Text fw={600} fz="sm" lh={1.3}>{slot.label}</Text>
|
||||
{slot.required && !doc && <Text span c="red" fz="xs">*</Text>}
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.3}>{slot.description}</Text>
|
||||
</div>
|
||||
<Badge size="xs" variant="light" color={CAT_COLOR[slot.category]}>{slot.category}</Badge>
|
||||
</Group>
|
||||
{doc ? (
|
||||
<Group gap="xs" align="center">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{doc.file.name}</Text>
|
||||
<Text fz="xs" c="dimmed">{doc.uploadedAt}</Text>
|
||||
<Menu position="bottom-end" shadow="sm" width={140} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs">
|
||||
<IconDotsVertical size={13} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEye size={13} />}
|
||||
onClick={() => setPreviewDoc({ name: doc.file.name, url: doc.url, isImage: doc.file.type.startsWith('image/') })}>
|
||||
Preview
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconDownload size={13} />} component="a" href={doc.url} download={doc.file.name}>
|
||||
Download
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconTrash size={13} />} color="red" onClick={() => handleRemove(slot.key)}>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={handleUpload(slot.key)} accept={slot.accept}>
|
||||
{(props) => {
|
||||
resetRefs.current[slot.key] = resetRef.current;
|
||||
return (
|
||||
<Button size="xs" variant="light" leftSection={<IconUpload size={13} />} fullWidth {...props}>
|
||||
Upload Document
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Paper withBorder radius="md" p="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconFileDescription size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No documents match your search.</Text>
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setSearch(''); setCategory('All'); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewDoc}
|
||||
onClose={() => setPreviewDoc(null)}
|
||||
title={<Text fw={700}>{previewDoc?.name}</Text>}
|
||||
size="xl"
|
||||
centered
|
||||
styles={{ body: { padding: 0, minHeight: rem(500) } }}
|
||||
>
|
||||
{previewDoc && (
|
||||
previewDoc.isImage
|
||||
? <img src={previewDoc.url} alt={previewDoc.name} style={{ width: '100%', borderRadius: rem(8) }} />
|
||||
: <iframe src={previewDoc.url} title={previewDoc.name} style={{ width: '100%', height: rem(500), border: 'none' }} />
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="My documents"
|
||||
description="A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentVaultPage;
|
||||
|
||||
@@ -1,438 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CountrySelect, getCountryName } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconRubberStamp,
|
||||
IconShieldCheck,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — existing endorsement applications
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_ENDORSEMENTS = [
|
||||
{
|
||||
id: 'END-APP-2025-001',
|
||||
cocType: 'Officer in Charge of a Navigational Watch (STCW II/1)',
|
||||
foreignCocNo: 'PHL-COC-2022-0045',
|
||||
issuingCountry: 'Philippines',
|
||||
submitted: '2025-04-05',
|
||||
status: 'Document Verification',
|
||||
statusColor: 'blue',
|
||||
statusNote: 'EMA is verifying your documents. You will be notified when verification is complete.',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ISSUED = [
|
||||
{
|
||||
id: 'EMA-END-2024-012',
|
||||
cocType: 'Chief Mate — STCW II/2',
|
||||
foreignCocNo: 'GRC-COC-2019-0033',
|
||||
issuingCountry: 'Greece',
|
||||
endorsementNo: 'EMA-END-2024-012',
|
||||
issued: '2024-08-10',
|
||||
expiry: '2029-06-15',
|
||||
status: 'Valid',
|
||||
statusColor: 'teal',
|
||||
},
|
||||
];
|
||||
|
||||
// blank PDF
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Docs {
|
||||
foreignCoc: File | null;
|
||||
translation: File | null;
|
||||
medical: File | null;
|
||||
seamanBook: File | null;
|
||||
photo: File | null;
|
||||
}
|
||||
|
||||
function ApplicationWizard({ onDone }: { onDone: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [cocNo, setCocNo] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [country, setCountry] = useState<string | null>(null);
|
||||
const [cocType, setCocType] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
|
||||
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Stack gap="lg" align="center" py="xl">
|
||||
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
|
||||
<Title order={3} ta="center">Application Submitted</Title>
|
||||
<Text c="dimmed" ta="center" maw={400}>
|
||||
Your endorsement application has been submitted. EMA officers will verify your documents
|
||||
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
|
||||
</Text>
|
||||
<Button onClick={onDone}>Back to Endorsements</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stepper active={step} size="sm">
|
||||
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
|
||||
<Stepper.Step label="Upload Documents" description="Required documents" />
|
||||
<Stepper.Step label="Payment" description="Pay endorsement fee" />
|
||||
<Stepper.Step label="Review & Submit" description="Final check" />
|
||||
</Stepper>
|
||||
|
||||
{/* Step 0 — Foreign CoC details */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
|
||||
<Text fz="sm">
|
||||
<strong>STCW Regulation I/10</strong> — EMA will endorse your foreign CoC so it is
|
||||
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
|
||||
for the same period as your foreign CoC.
|
||||
</Text>
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
|
||||
<CountrySelect label="Issuing Country" value={country} onChange={setCountry} required />
|
||||
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
|
||||
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
|
||||
<AmharicDatePicker label="Issue Date" value={issueDate} onChange={setIssueDate} required />
|
||||
<AmharicDatePicker label="Expiry Date" value={expiryDate} onChange={setExpiryDate} required />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — Documents */}
|
||||
{step === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
|
||||
All documents must be clear, legible, and complete.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Required Documents</Text>
|
||||
<Stack gap="md">
|
||||
{[
|
||||
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
|
||||
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
|
||||
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
|
||||
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
|
||||
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
|
||||
].map((slot) => (
|
||||
<FileInput
|
||||
key={slot.key}
|
||||
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={docs[slot.key]}
|
||||
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
clearable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Upload checklist */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
|
||||
<Stack gap={4}>
|
||||
{[
|
||||
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
|
||||
{ label: 'Medical Cert', done: !!docs.medical },
|
||||
{ label: 'Seaman Book', done: !!docs.seamanBook },
|
||||
{ label: 'Photo', done: !!docs.photo },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
|
||||
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Payment */}
|
||||
{step === 2 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Endorsement Fee</Text>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 300 },
|
||||
{ label: 'Document Verification Fee', amount: 200 },
|
||||
{ label: 'Endorsement Issuance Fee', amount: 500 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb="xs">
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={600}>ETB {amount}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fw={800}>Total</Text>
|
||||
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
|
||||
</Text>
|
||||
</Alert>
|
||||
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Review */}
|
||||
{step === 3 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="lg">Review Your Application</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
|
||||
{[
|
||||
['CoC Number', cocNo],
|
||||
['Country', getCountryName(country, 'en')],
|
||||
['Issuer', issuer],
|
||||
['CoC Type', cocType],
|
||||
['Issue Date', issueDate],
|
||||
['Expiry Date', expiryDate],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
|
||||
<List spacing="xs" size="sm">
|
||||
{[
|
||||
{ label: 'Foreign CoC', file: docs.foreignCoc },
|
||||
{ label: 'Medical Certificate', file: docs.medical },
|
||||
{ label: 'Seaman Book', file: docs.seamanBook },
|
||||
{ label: 'Photo', file: docs.photo },
|
||||
{ label: 'Translation', file: docs.translation },
|
||||
].map(({ label, file }) => file && (
|
||||
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
|
||||
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
|
||||
<Text fz="xs">
|
||||
By submitting you confirm that all information is accurate and the documents are genuine.
|
||||
Providing false information is an offence under the Maritime Code.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
|
||||
onClick={() => setStep(s => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function EndorsementPage() {
|
||||
const navigate = useNavigate();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
|
||||
if (applying) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Apply for Endorsement</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag State Endorsement of Foreign CoC</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<ApplicationWizard onDone={() => setApplying(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Title order={3}>Endorsements</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag-state endorsement of foreign-issued Certificates of Competency</Text>
|
||||
</div>
|
||||
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
|
||||
Apply for Endorsement
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Info panel */}
|
||||
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} fz="sm">What is an Endorsement?</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
{[
|
||||
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
|
||||
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 10–15 working days after all documents are verified.' },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
||||
<Text fz="xs" fw={700}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Active applications */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsement Applications</Text>
|
||||
{MOCK_ENDORSEMENTS.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active endorsement applications.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{MOCK_ENDORSEMENTS.map((app) => (
|
||||
<Paper key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={700}>{app.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {app.submitted}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={app.statusColor} variant="light">{app.status}</Badge>
|
||||
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Alert variant="light" color={app.statusColor} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
|
||||
<Text fz="xs">{app.statusNote}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issued endorsements */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsements</Text>
|
||||
{MOCK_ISSUED.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No endorsements issued yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{MOCK_ISSUED.map((end) => (
|
||||
<Card key={end.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{end.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={end.statusColor} variant="light">{end.status}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
||||
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
|
||||
</SimpleGrid>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewId}
|
||||
onClose={() => setPreviewId(null)}
|
||||
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsements"
|
||||
description="Endorsements are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementPage;
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
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)} />
|
||||
<AmharicDatePicker label="License Validity Date" required value={licenseValidityDate} onChange={setLicenseValidityDate} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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,123 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Grid,
|
||||
NumberInput,
|
||||
Select,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
type FormSectionConfig,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
values: Record<string, unknown>;
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one form section from the license type's configuration.
|
||||
*
|
||||
* Nothing here is Freight-Forwarder specific — adding a license type or moving
|
||||
* a field is a backend config change, which is the whole point of the
|
||||
* config-driven design.
|
||||
*/
|
||||
export function ConfigDrivenSection({
|
||||
section,
|
||||
values,
|
||||
formData,
|
||||
onChange,
|
||||
disabled,
|
||||
errors = {},
|
||||
}: Props) {
|
||||
const fields = [...(section.fields ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{fields.map((field) => {
|
||||
if (!conditionHolds(field.showWhen, formData)) return null;
|
||||
|
||||
const label = localized(field.label);
|
||||
const value = values?.[field.key];
|
||||
const error = errors[`${section.key}.${field.key}`];
|
||||
const common = {
|
||||
label,
|
||||
description: localized(field.helpText) || undefined,
|
||||
withAsterisk: field.required,
|
||||
error,
|
||||
// Read-only fields come from the user account and must not be edited.
|
||||
disabled: disabled || field.readOnly,
|
||||
};
|
||||
const span = field.type === 'TEXTAREA' ? 12 : 6;
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
{field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
value: o.value,
|
||||
label: localized(o.label),
|
||||
}))}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
clearable={!field.required}
|
||||
/>
|
||||
) : field.type === 'BOOLEAN' ? (
|
||||
<Checkbox
|
||||
label={label}
|
||||
error={error}
|
||||
disabled={common.disabled}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.checked)}
|
||||
mt="md"
|
||||
/>
|
||||
) : field.type === 'NUMBER' || field.type === 'MONEY' ? (
|
||||
<NumberInput
|
||||
{...common}
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(v) => onChange(field.key, v === '' ? null : Number(v))}
|
||||
// Deliberately not clamped with min/max: Mantine would rewrite
|
||||
// the entered figure on blur, quietly turning a capital of
|
||||
// 900,000 into the 1,500,000 threshold. Validation reports the
|
||||
// problem instead, leaving what the applicant typed intact.
|
||||
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
|
||||
/>
|
||||
) : field.type === 'DATE' ? (
|
||||
<TextInput
|
||||
{...common}
|
||||
type="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
) : field.type === 'TEXTAREA' ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
autosize
|
||||
minRows={3}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
{...common}
|
||||
type={field.type === 'EMAIL' ? 'email' : 'text'}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconFileUpload,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
requirements: DocumentRequirement[];
|
||||
attachments: Attachment[];
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
ownerType: 'APPLICATION' | 'APPLICATION_STAFF';
|
||||
ownerId: string;
|
||||
/** Document keys the officer flagged; these render as "needs fixing". */
|
||||
flagged?: Record<string, string>;
|
||||
/** When set, only flagged slots accept a new upload. */
|
||||
restrictToFlagged?: boolean;
|
||||
onUploaded: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload slots for an application, driven by the configured document
|
||||
* requirements. Conditional slots appear only when their rule matches the
|
||||
* answers given — an owned vehicle asks for a libre, a rented one for the
|
||||
* rental agreement.
|
||||
*/
|
||||
export function DocumentSlots({
|
||||
requirements,
|
||||
attachments,
|
||||
formData,
|
||||
ownerType,
|
||||
ownerId,
|
||||
flagged = {},
|
||||
restrictToFlagged = false,
|
||||
onUploaded,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
|
||||
const required = requirements.filter(
|
||||
(r) =>
|
||||
r.mode === 'ALWAYS' ||
|
||||
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
|
||||
);
|
||||
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
setBusy(documentKey);
|
||||
setError(null);
|
||||
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
|
||||
setBusy(null);
|
||||
resetRefs.current[documentKey]?.();
|
||||
if (result.ok) onUploaded();
|
||||
else setError(result.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{required.map((requirement) => {
|
||||
const existing = attachments.find((a) => a.documentKey === requirement.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
const flagRemark = flagged[requirement.key];
|
||||
const locked = readOnly || (restrictToFlagged && !flagRemark);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={requirement.key}
|
||||
withBorder
|
||||
padding="md"
|
||||
style={{
|
||||
borderColor: flagRemark
|
||||
? 'var(--mantine-color-orange-5)'
|
||||
: uploaded
|
||||
? 'var(--mantine-color-teal-4)'
|
||||
: undefined,
|
||||
borderStyle: uploaded ? 'solid' : 'dashed',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{localized(requirement.name)}
|
||||
</Text>
|
||||
{requirement.mode === 'CONDITIONAL' && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
conditional
|
||||
</Badge>
|
||||
)}
|
||||
{uploaded && !flagRemark && (
|
||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
uploaded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{existing?.files?.[0] && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{existing.files[0].originalName} ·{' '}
|
||||
{(existing.files[0].sizeBytes / 1024).toFixed(0)} KB
|
||||
</Text>
|
||||
)}
|
||||
{flagRemark && (
|
||||
<Text size="xs" c="orange.7" mt={4}>
|
||||
Officer: {flagRemark}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{existing?.files?.[0]?.url && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
component="a"
|
||||
href={existing.files[0].url}
|
||||
target="_blank"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
<FileButton
|
||||
resetRef={(r) => {
|
||||
if (r) resetRefs.current[requirement.key] = r;
|
||||
}}
|
||||
onChange={(file) => handle(requirement.key, file)}
|
||||
accept={requirement.allowedMimeTypes?.join(',')}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
variant={uploaded ? 'light' : 'filled'}
|
||||
leftSection={
|
||||
busy === requirement.key ? (
|
||||
<Loader size={12} />
|
||||
) : (
|
||||
<IconFileUpload size={14} />
|
||||
)
|
||||
}
|
||||
disabled={busy === requirement.key}
|
||||
>
|
||||
{uploaded ? 'Replace' : 'Upload'}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBuildingWarehouse,
|
||||
IconChevronRight,
|
||||
IconFileText,
|
||||
IconShip,
|
||||
IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The licence catalogue an applicant chooses from, grouped by category.
|
||||
*
|
||||
* Shared by the dashboard and the applications page so the two never drift —
|
||||
* they previously listed the same licence types in two different shapes.
|
||||
*/
|
||||
|
||||
const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
|
||||
CARGO_FREIGHT: IconBuildingWarehouse,
|
||||
SHIPPING_AGENCY: IconShip,
|
||||
INVESTMENT: IconTrendingUp,
|
||||
};
|
||||
|
||||
function formatFee(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return 'No fee';
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value)) return 'No fee';
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
export function LicenseCatalogue() {
|
||||
const navigate = useNavigate();
|
||||
const { data: types } = useGetLicenseTypesQuery();
|
||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||
|
||||
const { groups, orphans } = useMemo(() => {
|
||||
const active = (types?.items ?? []).filter((t) => t.isActive);
|
||||
const catalogue = (categories?.items ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
const known = new Set(catalogue.map((c) => c.key));
|
||||
return {
|
||||
groups: catalogue
|
||||
.map((category) => ({
|
||||
category,
|
||||
licenseTypes: active.filter((t) => t.category === category.key),
|
||||
}))
|
||||
.filter((g) => g.licenseTypes.length > 0),
|
||||
// A licence type whose category has no catalogue entry would otherwise
|
||||
// vanish from the page entirely.
|
||||
orphans: active.filter((t) => !known.has(t.category)),
|
||||
};
|
||||
}, [types, categories]);
|
||||
|
||||
if (groups.length === 0 && orphans.length === 0) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Center>
|
||||
<Stack gap={6} align="center">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconFileText size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No licence types are available yet. Contact EMA if you were
|
||||
expecting one.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{groups.map(({ category, licenseTypes }) => (
|
||||
<CategoryGroup
|
||||
key={category.key}
|
||||
icon={CATEGORY_ICONS[category.key] ?? IconFileText}
|
||||
title={localized(category.name)}
|
||||
description={localized(category.description)}
|
||||
licenseTypes={licenseTypes}
|
||||
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
|
||||
/>
|
||||
))}
|
||||
{orphans.length > 0 && (
|
||||
<CategoryGroup
|
||||
icon={IconFileText}
|
||||
title="Other licences"
|
||||
description="Licence types that have not been assigned a category."
|
||||
licenseTypes={orphans}
|
||||
onSelect={(type) => navigate(`/licensing/${type.key}/apply`)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryGroup({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
licenseTypes,
|
||||
onSelect,
|
||||
}: {
|
||||
icon: typeof IconShip;
|
||||
title: string;
|
||||
description: string;
|
||||
licenseTypes: LicenseType[];
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Group gap="xs" mb="xs">
|
||||
<ThemeIcon variant="light" color="emaTeal" radius="md" size="md">
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{licenseTypes.map((type) => (
|
||||
<LicenseTypeCard key={type.id} type={type} onSelect={onSelect} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseTypeCard({
|
||||
type,
|
||||
onSelect,
|
||||
}: {
|
||||
type: LicenseType;
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: 'pointer', height: '100%' }}
|
||||
onClick={() => onSelect(type)}
|
||||
>
|
||||
<Stack gap="xs" justify="space-between" h="100%">
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text fw={600} size="sm" lh={1.35}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
</Group>
|
||||
{type.description && (
|
||||
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
||||
{localized(type.description)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Group gap={6} mt="sm">
|
||||
<Badge size="sm" variant="light" color="emaPrimary">
|
||||
{formatFee(type.feeNewApplication, type.feeCurrency)}
|
||||
</Badge>
|
||||
{capital && (
|
||||
<Tooltip label="Minimum capital that must be evidenced by a bank letter">
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Capital {capital.toLocaleString('en-US')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{type.issuesCertificate ? (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{type.validityMonths} months
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="Concludes with an EMA decision rather than a certificate">
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Evaluation only
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
>
|
||||
Start application
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseCatalogue;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
uploadDocument,
|
||||
useGetAttachmentsQuery,
|
||||
type StaffEvidenceRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface Props {
|
||||
staffId: string;
|
||||
evidence: StaffEvidenceRequirement[];
|
||||
readOnly?: boolean;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-person evidence uploads (CV, work agreement, ERB certificate).
|
||||
*
|
||||
* These attach to the staff member rather than the application, which is why
|
||||
* staff are stored as real rows: each needs a stable owner for their files.
|
||||
*/
|
||||
export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props) {
|
||||
const { data: attachments = [], refetch } = useGetAttachmentsQuery({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
ownerId: staffId,
|
||||
});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
if (!evidence?.length) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{evidence.map((item) => {
|
||||
const uploaded = attachments.some(
|
||||
(a) => a.documentKey === item.docKey && a.files?.length,
|
||||
);
|
||||
return (
|
||||
<FileButton
|
||||
key={item.docKey}
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
setBusy(item.docKey);
|
||||
await uploadDocument({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
ownerId: staffId,
|
||||
documentKey: item.docKey,
|
||||
file,
|
||||
});
|
||||
setBusy(null);
|
||||
refetch();
|
||||
onUploaded();
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant={uploaded ? 'light' : 'outline'}
|
||||
color={uploaded ? 'teal' : item.mandatory ? 'blue' : 'gray'}
|
||||
disabled={readOnly || busy === item.docKey}
|
||||
leftSection={
|
||||
busy === item.docKey ? (
|
||||
<Loader size={10} />
|
||||
) : uploaded ? (
|
||||
<IconCheck size={12} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{localized(item.label)}
|
||||
{item.mandatory && !uploaded ? ' *' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
localized,
|
||||
validateSections,
|
||||
useAddStaffMutation,
|
||||
useCreateApplicationMutation,
|
||||
useGetApplicationQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
usePatchSectionMutation,
|
||||
useRemoveStaffMutation,
|
||||
useResolveRemarkMutation,
|
||||
useResubmitApplicationMutation,
|
||||
useSubmitApplicationMutation,
|
||||
type FieldErrors,
|
||||
type ValidationIssue,
|
||||
} from '@ema-platform/api';
|
||||
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
|
||||
import { DocumentSlots } from '../components/DocumentSlots';
|
||||
import { StaffEvidence } from '../components/StaffEvidence';
|
||||
|
||||
/**
|
||||
* The applicant wizard, rendered entirely from the license type's
|
||||
* configuration. The same page serves every license type — the route's
|
||||
* `typeCode` decides which configuration is loaded.
|
||||
*/
|
||||
export function LicenseApplicationPage() {
|
||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
// Create (or resume) the draft up front, so uploads have a real owner to
|
||||
// attach to and nothing is lost if the browser is closed mid-wizard.
|
||||
useEffect(() => {
|
||||
if (appId || !config) return;
|
||||
createApplication({ licenseType: typeCode })
|
||||
.unwrap()
|
||||
.then((app) => setAppId(app.id))
|
||||
.catch((err) =>
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not start application',
|
||||
message: extractErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
}, [appId, config, createApplication, typeCode]);
|
||||
|
||||
const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
|
||||
skip: !appId,
|
||||
});
|
||||
const { data: attachments = [], refetch: refetchAttachments } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'APPLICATION', ownerId: appId as string },
|
||||
{ skip: !appId },
|
||||
);
|
||||
|
||||
const [patchSection] = usePatchSectionMutation();
|
||||
const [submitApplication, { isLoading: submitting }] = useSubmitApplicationMutation();
|
||||
const [resubmitApplication, { isLoading: resubmitting }] = useResubmitApplicationMutation();
|
||||
const [resolveRemark] = useResolveRemarkMutation();
|
||||
const [addStaff] = useAddStaffMutation();
|
||||
const [removeStaff] = useRemoveStaffMutation();
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
|
||||
const [issues, setIssues] = useState<ValidationIssue[]>([]);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [staffModal, setStaffModal] = useState<string | null>(null);
|
||||
const [newStaff, setNewStaff] = useState({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
|
||||
// Seed local edits from the server copy once it arrives.
|
||||
useEffect(() => {
|
||||
if (detail?.application?.formData) setDraft(detail.application.formData);
|
||||
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
||||
|
||||
const application = detail?.application;
|
||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
|
||||
const flaggedSections = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks.filter((r) => r.targetType === 'FORM_SECTION').map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
);
|
||||
const flaggedDocuments = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks.filter((r) => r.targetType === 'DOCUMENT').map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
);
|
||||
|
||||
// Sections that share a group collapse onto one step, so the stepper stays
|
||||
// short instead of showing a page per section.
|
||||
const steps = useMemo(
|
||||
() => buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft),
|
||||
[config, draft],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
[steps],
|
||||
);
|
||||
|
||||
if (loadingConfig || !config || !appId || !application) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
|
||||
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
try {
|
||||
await patchSection({
|
||||
id: appId as string,
|
||||
sectionKey,
|
||||
values: draft[sectionKey] ?? {},
|
||||
}).unwrap();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not save',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setIssues([]);
|
||||
if (!readOnly && currentStep?.sections?.length) {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: 'Complete the highlighted fields before submitting.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const section of sections) await saveSection(section.key);
|
||||
try {
|
||||
if (isAdjusting) {
|
||||
for (const remark of openRemarks) {
|
||||
await resolveRemark({ id: appId as string, remarkId: remark.id }).unwrap();
|
||||
}
|
||||
await resubmitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Resubmitted',
|
||||
message: 'Your corrections were sent back to the reviewing officer.',
|
||||
});
|
||||
} else {
|
||||
await submitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Application submitted',
|
||||
message: 'You will be notified as it progresses.',
|
||||
});
|
||||
}
|
||||
navigate('/licensing/applications');
|
||||
} catch (err) {
|
||||
const found = extractValidationIssues(err);
|
||||
setIssues(found);
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Application incomplete',
|
||||
message: found.length
|
||||
? `${found.length} item(s) still need attention.`
|
||||
: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currentStep = steps[active];
|
||||
|
||||
/**
|
||||
* Checks the current step before moving on.
|
||||
*
|
||||
* The server rejects an incomplete application anyway, but only at submit —
|
||||
* by then the applicant has walked through every step and has to hunt for
|
||||
* what was missing. Validating per step points at the field directly.
|
||||
*/
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
// The wizard does not render until the configuration has loaded, but this
|
||||
// is declared above that guard, so narrow it here too.
|
||||
if (!currentStep || !config) return true;
|
||||
|
||||
if (currentStep.kind === 'sections') {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === 'staff') {
|
||||
const missing = config.staffRoleRequirements
|
||||
.filter(
|
||||
(role) =>
|
||||
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey).length <
|
||||
role.minCount,
|
||||
)
|
||||
.map((role) => `${localized(role.name)} (${role.minCount} required)`);
|
||||
if (missing.length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Staff incomplete',
|
||||
message: `Still needed: ${missing.join(', ')}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === 'documents') {
|
||||
const supplied = new Set(
|
||||
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
|
||||
);
|
||||
const missing = config.documentRequirements
|
||||
.filter(
|
||||
(req) =>
|
||||
req.mode === 'ALWAYS' ||
|
||||
(req.mode === 'CONDITIONAL' && conditionHolds(req.conditionExpression, draft)),
|
||||
)
|
||||
.filter((req) => !supplied.has(req.key))
|
||||
.map((req) => localized(req.name));
|
||||
if (missing.length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Documents missing',
|
||||
message: `Upload: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` and ${missing.length - 3} more` : ''}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
// A locked step during an adjustment round has nothing to validate.
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
if (currentStep?.kind === 'sections') {
|
||||
for (const section of currentStep.sections) await saveSection(section.key);
|
||||
}
|
||||
setFieldErrors({});
|
||||
setActive((s) => Math.min(steps.length - 1, s + 1));
|
||||
}
|
||||
|
||||
/** Going back is always allowed; going forward validates each step passed. */
|
||||
async function goToStep(target: number) {
|
||||
if (target <= active) {
|
||||
setActive(target);
|
||||
return;
|
||||
}
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
if (currentStep?.kind === 'sections') {
|
||||
for (const section of currentStep.sections) await saveSection(section.key);
|
||||
}
|
||||
setFieldErrors({});
|
||||
setActive(active + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<div>
|
||||
<Title order={3}>{localized(config.licenseType.name)}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{application.applicationNumber} ·{' '}
|
||||
<Badge size="sm" variant="light">
|
||||
{application.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</Text>
|
||||
</div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Fee: {config.fee ?? '—'} {config.feeCurrency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isAdjusting && (
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title="Corrections requested"
|
||||
mb="md"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{openRemarks.map((remark) => (
|
||||
<Text size="sm" key={remark.id}>
|
||||
<b>{remark.targetKey}</b>: {remark.remark}
|
||||
</Text>
|
||||
))}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Only the items listed above can be changed.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
|
||||
<Stack gap={2}>
|
||||
{issues.map((issue, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
• {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
||||
{steps.map((step) => (
|
||||
<Stepper.Step key={step.key} label={step.label} />
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{currentStep?.kind === 'sections' && (
|
||||
<Stack gap="lg">
|
||||
{currentStep.sections.map((section, index) => {
|
||||
const locked = isAdjusting && !flaggedSections[section.key];
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{index > 0 && <Divider mb="lg" />}
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
{locked && (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
|
||||
This section was accepted and is locked for this round.
|
||||
</Alert>
|
||||
)}
|
||||
<ConfigDrivenSection
|
||||
section={section}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
disabled={readOnly || locked}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
}));
|
||||
// Clear the error as soon as the applicant addresses it.
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${section.key}.${key}`];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'staff' && (
|
||||
<Stack>
|
||||
{config.staffRoleRequirements.map((role) => {
|
||||
const members = (detail?.staff ?? []).filter((s) => s.roleKey === role.roleKey);
|
||||
return (
|
||||
<Card withBorder key={role.roleKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{localized(role.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{members.length} of {role.minCount} required
|
||||
{role.requiredEvidence.length > 0 &&
|
||||
` · each needs ${role.requiredEvidence
|
||||
.filter((e) => e.mandatory)
|
||||
.map((e) => localized(e.label))
|
||||
.join(', ')}`}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{members.length >= role.minCount && (
|
||||
<Badge color="teal" size="sm" leftSection={<IconCheck size={10} />}>
|
||||
complete
|
||||
</Badge>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setStaffModal(role.roleKey)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
{members.map((member) => (
|
||||
<Card withBorder key={member.id} padding="sm" radius="sm">
|
||||
<Group justify="space-between" mb={member.id ? 'xs' : 0}>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{member.fullName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{member.position ?? '—'}
|
||||
{member.yearsOfExperience
|
||||
? ` · ${member.yearsOfExperience} yrs`
|
||||
: ''}
|
||||
</Text>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await removeStaff({ id: appId, staffId: member.id });
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
<StaffEvidence
|
||||
staffId={member.id}
|
||||
evidence={role.requiredEvidence}
|
||||
readOnly={readOnly}
|
||||
onUploaded={refetch}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'documents' && (
|
||||
<DocumentSlots
|
||||
requirements={config.documentRequirements}
|
||||
attachments={attachments}
|
||||
formData={draft}
|
||||
ownerType="APPLICATION"
|
||||
ownerId={appId}
|
||||
flagged={flaggedDocuments}
|
||||
restrictToFlagged={isAdjusting}
|
||||
readOnly={readOnly}
|
||||
onUploaded={() => {
|
||||
refetchAttachments();
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'review' && (
|
||||
<Stack>
|
||||
{currentStep.sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
<ConfigDrivenSection
|
||||
section={section}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
disabled={readOnly}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
}));
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`${section.key}.${key}`];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Divider my="lg" />
|
||||
</div>
|
||||
))}
|
||||
<Title order={5}>Review</Title>
|
||||
{sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} size="sm" mb={4}>
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{(section.fields ?? [])
|
||||
.filter((f) => conditionHolds(f.showWhen, draft))
|
||||
.map((field) => (
|
||||
<Table.Tr key={field.key}>
|
||||
<Table.Td w="45%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{String(draft[section.key]?.[field.key] ?? '—')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Divider my="sm" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setActive((s) => Math.max(0, s - 1))}
|
||||
disabled={active === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{active < steps.length - 1 ? (
|
||||
<Button onClick={handleContinue}>Continue</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="teal"
|
||||
loading={submitting || resubmitting}
|
||||
disabled={readOnly}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(staffModal)}
|
||||
onClose={() => setStaffModal(null)}
|
||||
title="Add staff member"
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Full name"
|
||||
withAsterisk
|
||||
value={newStaff.fullName}
|
||||
onChange={(e) => setNewStaff({ ...newStaff, fullName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Position"
|
||||
value={newStaff.position}
|
||||
onChange={(e) => setNewStaff({ ...newStaff, position: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Years of experience"
|
||||
value={newStaff.yearsOfExperience}
|
||||
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
||||
min={0}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseApplicationPage;
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useBypassPaymentMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* The applicant's landing page: which licences they can apply for, and the
|
||||
* state of anything already filed.
|
||||
*
|
||||
* The licence types come from the backend, so a newly configured type appears
|
||||
* here without a code change — and each one carries its own document
|
||||
* requirements into the wizard.
|
||||
*/
|
||||
export function MyApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
const result = await bypassPayment(applicationId).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: result.certificateIssued
|
||||
? 'The licence has been issued — see My licences below.'
|
||||
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Bypass failed',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the certificate belonging to an application.
|
||||
*
|
||||
* The applicant knows their application number, not the licence id, so the
|
||||
* licence is looked up from the list already loaded rather than making them
|
||||
* find it in a separate table.
|
||||
*/
|
||||
async function openCertificateForApplication(applicationId: string) {
|
||||
const licence = (licences?.items ?? []).find(
|
||||
(l) => l.applicationId === applicationId,
|
||||
);
|
||||
if (!licence) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'Certificate not ready',
|
||||
message:
|
||||
'The licence for this application has not been issued yet. It will appear under My licences.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await downloadCertificate(licence.id);
|
||||
}
|
||||
|
||||
async function downloadCertificate(licenseId: string) {
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not open the certificate',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Licence applications
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Choose a licence to apply for. Each one asks for its own forms and
|
||||
supporting documents.
|
||||
</Text>
|
||||
|
||||
<Box mb="xl">
|
||||
<LicenseCatalogue />
|
||||
</Box>
|
||||
|
||||
{(licences?.items ?? []).length > 0 && (
|
||||
<>
|
||||
<Title order={4} mb="sm">
|
||||
My licences
|
||||
</Title>
|
||||
<Card withBorder padding={0} mb="xl">
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate</Table.Th>
|
||||
<Table.Th>Licence</Table.Th>
|
||||
<Table.Th>Valid until</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(licences?.items ?? []).map((licence) => (
|
||||
<Table.Tr key={licence.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500} ff="monospace">
|
||||
{licence.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{localized(licence.licenseType?.name) || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(licence.expiryDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={licence.status === 'ACTIVE' ? 'green' : 'gray'}
|
||||
>
|
||||
{licence.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => downloadCertificate(licence.id)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Title order={4} mb="sm">
|
||||
My applications
|
||||
</Title>
|
||||
|
||||
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
|
||||
<Card withBorder padding="sm" radius="md" mb="sm"
|
||||
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
|
||||
<Text size="sm">
|
||||
Your payment has been received. The certificate is being prepared
|
||||
and will appear under My licences above once it is issued.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="dimmed">You have not filed any applications yet.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick a licence above to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding={0}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid and issues the licence"
|
||||
>
|
||||
Bypass payment
|
||||
</Button>
|
||||
)}
|
||||
{/* An issued application's primary action is the
|
||||
certificate. It used to be "View", which opened the
|
||||
application wizard — so the one thing the applicant
|
||||
came back for was the one thing the button did not do. */}
|
||||
{app.status === 'CERTIFICATE_ISSUED' && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => openCertificateForApplication(app.id)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
||||
variant={
|
||||
app.status === 'RESUBMIT_REQUIRED' ||
|
||||
app.status === 'PAYMENT_PENDING'
|
||||
? 'filled'
|
||||
: 'subtle'
|
||||
}
|
||||
color={
|
||||
app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'orange'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? 'yellow'
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
// Paying leaves the SPA for Telebirr, so this is a
|
||||
// provider hand-off rather than a route change.
|
||||
app.status === 'PAYMENT_PENDING'
|
||||
? pay(app.id)
|
||||
: navigate(
|
||||
`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT'
|
||||
? 'Continue'
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Fix & resubmit'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? 'Application'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApplicationsPage;
|
||||
@@ -1,164 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,334 +1,21 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock current certificate — replace with real API data
|
||||
// ---------------------------------------------------------------------------
|
||||
const MOCK_CURRENT: MedicalCert | null = {
|
||||
id: 'MC-2024-001',
|
||||
issuedBy: 'EMA Approved Medical Center — Addis Ababa',
|
||||
issuedDate: '2024-03-15',
|
||||
expiryDate: '2026-03-14',
|
||||
status: 'Expiring',
|
||||
restrictions: 'None',
|
||||
fileName: 'medical_cert_2024.pdf',
|
||||
};
|
||||
|
||||
const MOCK_HISTORY: MedicalCert[] = [
|
||||
{ id: 'MC-2022-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2022-03-10', expiryDate: '2024-03-09', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2022.pdf' },
|
||||
{ id: 'MC-2020-001', issuedBy: 'EMA Approved Medical Center', issuedDate: '2020-02-20', expiryDate: '2022-02-19', status: 'Expired', restrictions: 'None', fileName: 'medical_cert_2020.pdf' },
|
||||
];
|
||||
|
||||
interface MedicalCert {
|
||||
id: string;
|
||||
issuedBy: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending';
|
||||
restrictions: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
|
||||
};
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function MedicalCertificatePage() {
|
||||
const [current] = useState<MedicalCert | null>(MOCK_CURRENT);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [doctorName, setDoctorName] = useState('');
|
||||
const [issuedDate, setIssuedDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const resetRef = useRef<() => void>(null);
|
||||
|
||||
const days = current ? daysUntil(current.expiryDate) : 0;
|
||||
const progressVal = current
|
||||
? Math.max(0, Math.min(100, (days / 730) * 100))
|
||||
: 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uploadedFile || !issuedDate || !expiryDate) {
|
||||
notify.error('Please fill all fields and upload the certificate file.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setSubmitting(false);
|
||||
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
|
||||
setUploadedFile(null);
|
||||
setDoctorName('');
|
||||
setIssuedDate('');
|
||||
setExpiryDate('');
|
||||
resetRef.current?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Medical Certificate</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Validity alert */}
|
||||
{current && days <= 90 && days > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
|
||||
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
|
||||
Please visit an EMA-approved medical centre and upload your renewed certificate below.
|
||||
</Alert>
|
||||
)}
|
||||
{current && days <= 0 && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
|
||||
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
|
||||
</Alert>
|
||||
)}
|
||||
{!current && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Current certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="red" size={36} radius="md">
|
||||
<IconHeart size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Current Certificate</Text>
|
||||
</Group>
|
||||
|
||||
{current ? (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
|
||||
<Text fz="sm">{current.id}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
|
||||
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
|
||||
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
|
||||
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
|
||||
{formatDate(current.expiryDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
|
||||
<Text fz="sm">{current.restrictions}</Text>
|
||||
</Group>
|
||||
|
||||
{/* Validity bar */}
|
||||
<Box mt="xs">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz="xs" c="dimmed">Validity remaining</Text>
|
||||
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progressVal}
|
||||
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={13} />}
|
||||
mt="xs"
|
||||
>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box ta="center" py="xl">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconFileDescription size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No certificate on record</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Upload new certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconUpload size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Issuing Doctor / Medical Centre"
|
||||
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
|
||||
value={doctorName}
|
||||
onChange={(e) => setDoctorName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<AmharicDatePicker
|
||||
label="Issue Date"
|
||||
value={issuedDate}
|
||||
onChange={setIssuedDate}
|
||||
size="sm"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label="Expiry Date"
|
||||
value={expiryDate}
|
||||
onChange={setExpiryDate}
|
||||
size="sm"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
||||
{uploadedFile ? (
|
||||
<Card withBorder radius="sm" p="xs">
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File (PDF / JPG / PNG, max 5MB)
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">
|
||||
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
|
||||
Notifications will be sent by email and SMS.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconCheck size={15} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!uploadedFile || !issuedDate || !expiryDate}
|
||||
>
|
||||
Submit for Verification
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Notification schedule */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconCalendar size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Expiry Notification Schedule</Text>
|
||||
</Group>
|
||||
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">First reminder — time to book your medical examination</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Second reminder — urgent renewal required</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Final reminder — certificate expires very soon</Text>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Paper>
|
||||
|
||||
{/* History */}
|
||||
{MOCK_HISTORY.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">Certificate History</Text>
|
||||
<Stack gap="xs">
|
||||
{MOCK_HISTORY.map((cert) => (
|
||||
<Card key={cert.id} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="wrap" gap="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="gray" size={32} radius="md">
|
||||
<IconFileDescription size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.id}</Text>
|
||||
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Medical certificate"
|
||||
description="Medical certificates are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MedicalCertificatePage;
|
||||
|
||||
@@ -1,706 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { AmharicDatePicker } from '../../../components/AmharicDatePicker';
|
||||
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">
|
||||
<AmharicDatePicker label="Insurance Validity Date" required value={insuranceValidityDate} onChange={setInsuranceValidityDate} />
|
||||
<AmharicDatePicker label="Bond Validity Date" required value={bondValidityDate} onChange={setBondValidityDate} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconBell,
|
||||
IconBellOff,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconShield,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
localized,
|
||||
useGetNotificationsQuery,
|
||||
useMarkNotificationReadMutation,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
type NotifType = 'warning' | 'info' | 'success' | 'error';
|
||||
type NotifCategory = 'Medical' | 'Seaman Book' | 'BST' | 'Certificate' | 'Application' | 'System';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotifType;
|
||||
category: NotifCategory;
|
||||
title: string;
|
||||
message: string;
|
||||
date: string;
|
||||
read: boolean;
|
||||
actionLabel?: string;
|
||||
actionRoute?: string;
|
||||
}
|
||||
|
||||
const MOCK_NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
category: 'Medical',
|
||||
title: 'Medical Certificate Expiring Soon',
|
||||
message: 'Your medical certificate expires on 14 March 2026 — 45 days remaining. Visit an EMA-approved medical centre to renew before it lapses.',
|
||||
date: '2026-01-28',
|
||||
read: false,
|
||||
actionLabel: 'View Medical Certificate',
|
||||
actionRoute: '/medical-certificate',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
category: 'BST',
|
||||
title: 'Basic Safety Training Incomplete',
|
||||
message: '3 of your 5 Basic Safety Training certificates are missing (EFA, PSSR, SHPT). All 5 are required before you can apply for a Seaman Book.',
|
||||
date: '2026-01-25',
|
||||
read: false,
|
||||
actionLabel: 'Manage BST Certificates',
|
||||
actionRoute: '/basic-safety-training',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
category: 'Application',
|
||||
title: 'Seaman Book Application Under Review',
|
||||
message: 'Your Seaman Book application (SB-APP-2024-001) has been received and is currently under review by an EMA Registration Officer.',
|
||||
date: '2024-05-10',
|
||||
read: true,
|
||||
actionLabel: 'Track Application',
|
||||
actionRoute: '/seaman-book',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
category: 'BST',
|
||||
title: 'PST Certificate Verified',
|
||||
message: 'Your Personal Survival Techniques (PST) certificate has been verified and approved. Certificate No: PST-2023-BMS-0421.',
|
||||
date: '2023-04-15',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'success',
|
||||
category: 'BST',
|
||||
title: 'FPFF Certificate Verified',
|
||||
message: 'Your Fire Prevention & Fire Fighting (FPFF) certificate has been verified and approved. Certificate No: FPFF-2023-BMS-0421.',
|
||||
date: '2023-04-15',
|
||||
read: true,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'info',
|
||||
category: 'System',
|
||||
title: 'Profile Setup Incomplete',
|
||||
message: 'Your seafarer profile is 60% complete. Please upload your National ID, passport size photo and remaining documents to complete your registration.',
|
||||
date: '2024-01-05',
|
||||
read: true,
|
||||
actionLabel: 'Go to Document Vault',
|
||||
actionRoute: '/documents',
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_CONFIG: Record<NotifType, { color: string; icon: typeof IconBell }> = {
|
||||
warning: { color: 'orange', icon: IconAlertTriangle },
|
||||
info: { color: 'blue', icon: IconInfoCircle },
|
||||
success: { color: 'teal', icon: IconCircleCheck },
|
||||
error: { color: 'red', icon: IconAlertCircle },
|
||||
};
|
||||
|
||||
const CATEGORY_ICON: Record<NotifCategory, typeof IconBell> = {
|
||||
Medical: IconHeart,
|
||||
'Seaman Book': IconBook2,
|
||||
BST: IconShield,
|
||||
Certificate: IconFileDescription,
|
||||
Application: IconFileDescription,
|
||||
System: IconBell,
|
||||
};
|
||||
|
||||
const CATEGORIES = ['All', 'Medical', 'Seaman Book', 'BST', 'Certificate', 'Application', 'System'] as const;
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* The applicant's notification inbox.
|
||||
*
|
||||
* Backed by the real notification records the licensing workflow writes at
|
||||
* every transition — this previously listed a fixed array of invented alerts.
|
||||
*/
|
||||
export function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>(MOCK_NOTIFICATIONS);
|
||||
const [filter, setFilter] = useState<string>('All');
|
||||
const [readFilter, setReadFilter] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetNotificationsQuery();
|
||||
const [markRead] = useMarkNotificationReadMutation();
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = notifications.filter((n) => {
|
||||
const matchCat = filter === 'All' || n.category === filter;
|
||||
const matchRead =
|
||||
!readFilter ||
|
||||
(readFilter === 'Unread' && !n.read) ||
|
||||
(readFilter === 'Read' && n.read);
|
||||
return matchCat && matchRead;
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
const unread = items.filter((n) => !n.isSeen).length;
|
||||
|
||||
const markRead = (id: string) => {
|
||||
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||
};
|
||||
|
||||
const markAllRead = () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
notify.success('All notifications marked as read.');
|
||||
};
|
||||
|
||||
const deleteNotif = (id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
notify.info('Notification removed.');
|
||||
};
|
||||
async function open(id: string, seen: boolean, link?: string) {
|
||||
if (!seen) await markRead(id);
|
||||
if (link) navigate(link);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Title order={3}>Notifications</Title>
|
||||
{unreadCount > 0 && (
|
||||
<Badge color="red" variant="filled" circle size="lg">{unreadCount}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed">Stay up to date on your certificates, applications and deadlines</Text>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<Button size="sm" variant="light" leftSection={<IconCheck size={14} />} onClick={markAllRead}>
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Container size="md" py="md">
|
||||
<Title order={3}>Notifications</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
|
||||
</Text>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
{[
|
||||
{ label: 'All', count: notifications.length, color: 'gray', icon: IconBell },
|
||||
{ label: 'Unread', count: notifications.filter((n) => !n.read).length, color: 'blue', icon: IconBell },
|
||||
{ label: 'Warnings', count: notifications.filter((n) => n.type === 'warning').length, color: 'orange', icon: IconAlertTriangle },
|
||||
{ label: 'Actions Needed', count: notifications.filter((n) => !n.read && n.type !== 'success').length, color: 'red', icon: IconAlertCircle },
|
||||
].map(({ label, count, color, icon: Icon }) => (
|
||||
<Card key={label} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={color} size={36} radius="md">
|
||||
<Icon size={17} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="lg" fw={700} lh={1}>{count}</Text>
|
||||
<Text fz="xs" c="dimmed">{label}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Group gap={6} style={{ flex: 1 }}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
size="xs"
|
||||
variant={filter === cat ? 'filled' : 'light'}
|
||||
color="blue"
|
||||
onClick={() => setFilter(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
<Select
|
||||
size="xs"
|
||||
placeholder="All Status"
|
||||
data={['Unread', 'Read']}
|
||||
value={readFilter}
|
||||
onChange={setReadFilter}
|
||||
clearable
|
||||
style={{ width: rem(120) }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Notification list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl" ta="center">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconBellOff size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No notifications found</Text>
|
||||
<Button size="xs" variant="subtle" mt="xs" onClick={() => { setFilter('All'); setReadFilter(null); }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Paper>
|
||||
{items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No notifications yet.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You will be notified as your applications progress.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{filtered.map((n) => {
|
||||
const { color, icon: TypeIcon } = TYPE_CONFIG[n.type];
|
||||
const CatIcon = CATEGORY_ICON[n.category];
|
||||
|
||||
{items.map((n) => {
|
||||
const link = (n.metadata?.['callbackUrl'] as string) ?? undefined;
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
withBorder
|
||||
padding="md"
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
borderLeft: `3px solid var(--mantine-color-${color}-5)`,
|
||||
background: n.read ? undefined : 'var(--mantine-color-blue-light)',
|
||||
opacity: n.read ? 0.85 : 1,
|
||||
cursor: link ? 'pointer' : 'default',
|
||||
borderLeft: n.isSeen ? undefined : '3px solid var(--mantine-color-blue-5)',
|
||||
}}
|
||||
onClick={() => markRead(n.id)}
|
||||
onClick={() => open(n.id, n.isSeen, link)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ flex: 1 }}>
|
||||
<ThemeIcon variant="light" color={color} size={38} radius="md" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<TypeIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text fw={n.read ? 500 : 700} fz="sm">{n.title}</Text>
|
||||
{!n.read && <Badge size="xs" color="blue" variant="filled">New</Badge>}
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<CatIcon size={10} />}>
|
||||
{n.category}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" mb={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{localized(n.subject)}
|
||||
</Text>
|
||||
{!n.isSeen && (
|
||||
<Badge size="xs" variant="light">
|
||||
new
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz="sm" c="dimmed" lh={1.5}>{n.message}</Text>
|
||||
<Group gap="sm" mt={4}>
|
||||
<Text fz="xs" c="dimmed">{formatDate(n.date)}</Text>
|
||||
{n.actionLabel && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={color}
|
||||
component="a"
|
||||
href={n.actionRoute}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{n.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" style={{ flexShrink: 0 }}>
|
||||
{!n.read && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
size="sm"
|
||||
title="Mark as read"
|
||||
onClick={(e) => { e.stopPropagation(); markRead(n.id); }}
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{localized(n.content)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{new Date(n.createdAt).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
{!n.isSeen && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title="Delete"
|
||||
onClick={(e) => { e.stopPropagation(); deleteNotif(n.id); }}
|
||||
leftSection={<IconCheck size={12} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markRead(n.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
Mark read
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationsPage;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useInitiatePaymentMutation,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Starts a payment and hands the browser over to the provider.
|
||||
*
|
||||
* Telebirr on web answers with a `REDIRECT` action carrying a signed checkout
|
||||
* URL; on mobile it answers with `LAUNCH_APP` so the Telebirr app can be opened
|
||||
* directly. Anything else means the provider gave us nothing to act on, which
|
||||
* is reported rather than silently doing nothing.
|
||||
*/
|
||||
export function useApplicationPayment() {
|
||||
const [initiate, { isLoading }] = useInitiatePaymentMutation();
|
||||
const [redirecting, setRedirecting] = useState(false);
|
||||
|
||||
async function pay(
|
||||
applicationId: string,
|
||||
provider = 'TELEBIRR',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await initiate({
|
||||
id: applicationId,
|
||||
provider,
|
||||
// Deep links only work inside a mobile browser; assume web otherwise.
|
||||
platform: /Android|iPhone|iPad/i.test(navigator.userAgent)
|
||||
? 'mobile'
|
||||
: 'web',
|
||||
}).unwrap();
|
||||
|
||||
const action = result.clientAction;
|
||||
|
||||
if (action?.type === 'REDIRECT' && action.url) {
|
||||
setRedirecting(true);
|
||||
// Full navigation, not a router push: the destination is Telebirr.
|
||||
window.location.href = action.url;
|
||||
return;
|
||||
}
|
||||
|
||||
if (action?.type === 'LAUNCH_APP') {
|
||||
notifications.show({
|
||||
color: 'blue',
|
||||
title: 'Open Telebirr',
|
||||
message: `Approve the payment in your Telebirr app${
|
||||
action.receiveCode ? ` using code ${action.receiveCode}` : ''
|
||||
}.`,
|
||||
autoClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
color: 'orange',
|
||||
title: 'Payment could not be started',
|
||||
message: 'The payment provider did not return a checkout link.',
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Payment could not be started',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { pay, isPaying: isLoading || redirecting };
|
||||
}
|
||||
129
apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx
Normal file
129
apps/portal/src/app/features/payments/pages/PaymentCheckPage.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
} from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
/**
|
||||
* Where Telebirr returns the applicant after paying.
|
||||
*
|
||||
* The redirect usually beats the webhook, so the payment is polled for a
|
||||
* short while. Running out of attempts means "not confirmed yet" — never
|
||||
* "failed": telling someone their payment failed when the money has left
|
||||
* their account is the worst possible outcome here.
|
||||
*/
|
||||
export function PaymentCheckPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
const { data, refetch, isLoading } = useGetApplicationPaymentQuery(
|
||||
applicationId,
|
||||
{ skip: !applicationId },
|
||||
);
|
||||
|
||||
const status = data?.status ?? null;
|
||||
const settled = status === 'PAID' || status === 'FAILED' || status === 'CANCELLED';
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || settled || attempts >= MAX_ATTEMPTS) return;
|
||||
const timer = setTimeout(() => {
|
||||
refetch();
|
||||
setAttempts((n) => n + 1);
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [applicationId, settled, attempts, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'PAID') navigate(`/payments/success?applicationId=${applicationId}`);
|
||||
if (status === 'FAILED' || status === 'CANCELLED') {
|
||||
navigate(`/payments/failure?applicationId=${applicationId}`);
|
||||
}
|
||||
}, [status, applicationId, navigate]);
|
||||
|
||||
if (!applicationId) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={48} radius="xl" color="orange" variant="light">
|
||||
<IconAlertTriangle size={24} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>We could not identify this payment</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open the application from your list to check its payment status.
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const exhausted = attempts >= MAX_ATTEMPTS && !settled;
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
{exhausted ? (
|
||||
<>
|
||||
<ThemeIcon size={48} radius="xl" color="yellow" variant="light">
|
||||
<IconClockHour4 size={24} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>Still confirming your payment</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Telebirr has not confirmed this payment yet. If the money has
|
||||
left your account it will be applied automatically — there is no
|
||||
need to pay again.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
|
||||
Check again
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLoading || !settled ? <Loader /> : (
|
||||
<ThemeIcon size={48} radius="xl" color="teal" variant="light">
|
||||
<IconCircleCheck size={24} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Title order={4}>Confirming your payment…</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
This usually takes a few seconds. Please do not close this page.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentCheckPage;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
/** Shown when Telebirr reported the payment as failed or cancelled. */
|
||||
export function PaymentFailurePage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl" radius="md">
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={56} radius="xl" color="red" variant="light">
|
||||
<IconAlertTriangle size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Payment not completed</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{data?.failureReason
|
||||
? data.failureReason
|
||||
: 'The payment was not completed. Nothing has been charged.'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Your application is unchanged and you can try again at any time.
|
||||
</Text>
|
||||
<Group mt="md">
|
||||
<Button variant="default" onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentFailurePage;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
/** Confirmation that the licence fee has been received. */
|
||||
export function PaymentSuccessPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl" radius="md">
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
|
||||
<IconCircleCheck size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Payment received</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Thank you. Your licence fee has been paid and your application is
|
||||
being finalised. You will be notified when your certificate is ready.
|
||||
</Text>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Divider my="xs" w="100%" />
|
||||
<Stack gap={4} w="100%">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Amount</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(data.amount).toLocaleString()} {data.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Method</Text>
|
||||
<Text size="sm">{data.provider}</Text>
|
||||
</Group>
|
||||
{data.providerRef && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Reference</Text>
|
||||
<Text size="sm" ff="monospace">{data.providerRef}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{data.paidAt && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Paid</Text>
|
||||
<Text size="sm">{new Date(data.paidAt).toLocaleString()}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
|
||||
Back to my applications
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaymentSuccessPage;
|
||||
@@ -1,431 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconLogout2,
|
||||
IconMapPin,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import {
|
||||
authStorage,
|
||||
setUser,
|
||||
setCurrentProfile,
|
||||
logout,
|
||||
type AuthUser,
|
||||
type CurrentProfile,
|
||||
} from "@ema-platform/auth";
|
||||
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
|
||||
import {
|
||||
ProfileFormContent,
|
||||
profileSchema,
|
||||
type ProfileValues,
|
||||
} from "../../profile/components/ProfileFormContent";
|
||||
import {
|
||||
AddressFormContent,
|
||||
addressSchema,
|
||||
type AddressValues,
|
||||
} from "../../profile/components/AddressFormContent";
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Profile", icon: IconUser },
|
||||
{ label: "Address", icon: IconMapPin },
|
||||
];
|
||||
|
||||
function StepIndicator({
|
||||
active,
|
||||
completed,
|
||||
}: {
|
||||
active: number;
|
||||
completed: number[];
|
||||
}) {
|
||||
return (
|
||||
<Box mb={32}>
|
||||
<Group gap={0} align="center" wrap="nowrap">
|
||||
{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",
|
||||
boxShadow:
|
||||
isCurrent || isDone
|
||||
? "0 2px 8px rgba(34, 139, 230, 0.2)"
|
||||
: "none",
|
||||
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" }}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rem(2),
|
||||
backgroundColor: isDone
|
||||
? "var(--mantine-color-blue-8)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
marginBottom: rem(22),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [professions, setProfessions] = useState<
|
||||
Array<{ id: string; name: { en: string } }>
|
||||
>([]);
|
||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
||||
const [profileTrigger] = useApiMutation<{ id: string }>();
|
||||
const [addressTrigger] = useApiMutation<{ id: string }>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileCheckTrigger] = useApiMutation<{
|
||||
total: number;
|
||||
items: CurrentProfile[];
|
||||
}>();
|
||||
const [fetchProfessions] = useApiMutation<{
|
||||
count: number;
|
||||
items: Array<{ id: string; name: { en: string } }>;
|
||||
}>();
|
||||
const { handleError } = useErrorHandler();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchProfessions({ url: "/professions?take=100", method: "GET" })
|
||||
.unwrap()
|
||||
.then((data) => setProfessions(data.items ?? []))
|
||||
.catch(() => setProfessions([]))
|
||||
.finally(() => setProfessionsLoading(false));
|
||||
}, [fetchProfessions]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
);
|
||||
|
||||
const professionNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
professions.forEach((p) => {
|
||||
map[p.id] = p.name.en;
|
||||
});
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
const nameParts = useMemo(
|
||||
() => (user?.name?.en || "").trim().split(/\s+/),
|
||||
[user],
|
||||
);
|
||||
const profileDefaults: ProfileValues = useMemo(
|
||||
() => ({
|
||||
professionId: "",
|
||||
firstName: nameParts[0] || "",
|
||||
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(" ") : "",
|
||||
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : "",
|
||||
gender: "",
|
||||
dob: "",
|
||||
pob: "",
|
||||
maritalStatus: "",
|
||||
}),
|
||||
[nameParts],
|
||||
);
|
||||
|
||||
const addressDefaults: AddressValues = useMemo(
|
||||
() => ({
|
||||
idType: "",
|
||||
idNumber: "",
|
||||
nationality: "",
|
||||
primaryPhoneNumber: user?.phoneNumber || "",
|
||||
secondaryPhoneNumber: "",
|
||||
email: user?.email || "",
|
||||
regionId: "",
|
||||
cityId: "",
|
||||
subcityId: "",
|
||||
woredaId: "",
|
||||
kebeleId: "",
|
||||
streetAddress: "",
|
||||
postalAddress: "",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
emergencyContactRelation: "",
|
||||
}),
|
||||
[user],
|
||||
);
|
||||
|
||||
const {
|
||||
register: profileRegister,
|
||||
handleSubmit: profileHandleSubmit,
|
||||
formState: { errors: profileErrors },
|
||||
setValue: profileSetValue,
|
||||
watch: profileWatch,
|
||||
trigger: profileTriggerValidation,
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: profileDefaults,
|
||||
});
|
||||
|
||||
const {
|
||||
register: addressRegister,
|
||||
handleSubmit: addressHandleSubmit,
|
||||
formState: { errors: addressErrors },
|
||||
setValue: addressSetValue,
|
||||
watch: addressWatch,
|
||||
trigger: addressTriggerValidation,
|
||||
} = useForm<AddressValues>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
defaultValues: addressDefaults,
|
||||
});
|
||||
|
||||
const onNext = async () => {
|
||||
const valid = await profileTriggerValidation();
|
||||
if (!valid) return;
|
||||
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
|
||||
setActive((c) => c + 1);
|
||||
};
|
||||
|
||||
const onSubmitAddress = async () => {
|
||||
const valid = await addressTriggerValidation();
|
||||
if (!valid) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const pv = profileWatch();
|
||||
const av = addressWatch();
|
||||
const selectedProfessionName = professionNameMap[pv.professionId] ?? "";
|
||||
|
||||
const addressData = await addressTrigger({
|
||||
url: `/addresss`,
|
||||
method: "POST",
|
||||
body: {
|
||||
idType: av.idType,
|
||||
idNumber: av.idNumber,
|
||||
nationality: av.nationality,
|
||||
primaryPhoneNumber: av.primaryPhoneNumber,
|
||||
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
|
||||
email: av.email || undefined,
|
||||
regionId: av.regionId || undefined,
|
||||
cityId: av.cityId || undefined,
|
||||
subcityId: av.subcityId || undefined,
|
||||
woredaId: av.woredaId || undefined,
|
||||
kebeleId: av.kebeleId || undefined,
|
||||
streetAddress: av.streetAddress || undefined,
|
||||
postalAddess: av.postalAddress || undefined,
|
||||
emergencyContactName: av.emergencyContactName || undefined,
|
||||
emergencyContactPhone: av.emergencyContactPhone || undefined,
|
||||
emergencyContactRelation: av.emergencyContactRelation || undefined,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
const profileResult = await profileTrigger({
|
||||
url: "/profiles",
|
||||
method: "POST",
|
||||
body: {
|
||||
userId: user?.id,
|
||||
type: "SEAFARER",
|
||||
professionId: pv.professionId,
|
||||
firstName: pv.firstName,
|
||||
middleName: pv.middleName,
|
||||
lastName: pv.lastName,
|
||||
gender: pv.gender,
|
||||
dob: pv.dob,
|
||||
pob: pv.pob || undefined,
|
||||
maritalStatus: pv.maritalStatus,
|
||||
addressId: addressData.id,
|
||||
},
|
||||
}).unwrap();
|
||||
authStorage.setProfileId(profileResult.id);
|
||||
|
||||
const me = await meTrigger({ url: "/auth/me", method: "GET" }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
// POST /profiles doesn't return the profession relation the portal's nav/route
|
||||
// guard reads (PortalLayout.tsx) — re-fetch with it, same as LoginPage does on login.
|
||||
try {
|
||||
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
|
||||
const profileCheck = await profileCheckTrigger({
|
||||
url: `/profiles?q=${encodeURIComponent(q)}`,
|
||||
method: "GET",
|
||||
}).unwrap();
|
||||
if (profileCheck.total > 0 && profileCheck.items.length > 0) {
|
||||
dispatch(setCurrentProfile(profileCheck.items[0]));
|
||||
}
|
||||
} catch {
|
||||
// non-fatal — sidebar/route guard falls back to FALLBACK_ACCESS
|
||||
}
|
||||
|
||||
notify.success("Profile setup complete!");
|
||||
navigate("/dashboard");
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Text c="dimmed">Please log in first.</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh" bg="gray.0">
|
||||
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Title order={3}>Complete Your Profile</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Set up your profile and address to get started
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<StepIndicator active={active} completed={completed} />
|
||||
|
||||
{active === 0 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Personal Information
|
||||
</Text>
|
||||
<ProfileFormContent
|
||||
register={profileRegister}
|
||||
errors={profileErrors}
|
||||
setValue={profileSetValue}
|
||||
watch={profileWatch}
|
||||
trigger={profileTriggerValidation}
|
||||
professionsLoading={professionsLoading}
|
||||
professionOptions={professionOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{active === 1 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
Identity & Contact
|
||||
</Text>
|
||||
<AddressFormContent
|
||||
register={addressRegister}
|
||||
errors={addressErrors}
|
||||
setValue={addressSetValue}
|
||||
watch={addressWatch}
|
||||
trigger={addressTriggerValidation}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button
|
||||
variant="default"
|
||||
color="gray"
|
||||
leftSection={<IconLogout2 size={16} />}
|
||||
onClick={() => {
|
||||
dispatch(logout());
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconArrowLeft size={16} />}
|
||||
onClick={() => setActive((c) => c - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
)}
|
||||
{active < STEPS.length - 1 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={onNext}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="blue"
|
||||
leftSection={<IconCircleCheck size={16} />}
|
||||
onClick={onSubmitAddress}
|
||||
loading={submitting}
|
||||
>
|
||||
Complete Setup
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export const addressSchema = z.object({
|
||||
kebeleId: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
postalAddress: z.string().optional(),
|
||||
// Emergency contact is collected but never required — leaving it blank must
|
||||
// not stop an applicant moving on.
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
@@ -150,7 +152,7 @@ export function AddressFormContent({
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Emergency Contact
|
||||
Emergency Contact <Text span c="dimmed" fz="xs" tt="none" fw={400}>(optional)</Text>
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PROFILE_FIELD_SECTION, useCurrentProfile } from '@ema-platform/auth';
|
||||
|
||||
const DISMISS_KEY = 'ema-portal-profile-nudge-dismissed';
|
||||
|
||||
/** Nothing below this is worth interrupting anyone about. */
|
||||
const NUDGE_THRESHOLD = 100;
|
||||
|
||||
/** How many gaps to name before falling back to a count. */
|
||||
const MAX_LISTED_GAPS = 3;
|
||||
|
||||
function readDismissed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === 'true';
|
||||
} catch {
|
||||
// Private mode — treat as not dismissed rather than hiding the nudge.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt to finish the profile. Explicitly not a gate.
|
||||
*
|
||||
* The applicant can dismiss it, and dismissing it persists. It never prevents
|
||||
* navigation and never appears on top of anything — replacing the wizard with
|
||||
* a modal would just be the same wall in a smaller box.
|
||||
*/
|
||||
export function ProfileCompletionNudge() {
|
||||
const { t } = useTranslation();
|
||||
const { completeness, missing, isLoading } = useCurrentProfile();
|
||||
const [dismissed, setDismissed] = useState(readDismissed);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, 'true');
|
||||
} catch {
|
||||
// Not persisting a dismissal is a smaller problem than crashing here.
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading || dismissed || completeness >= NUDGE_THRESHOLD) return null;
|
||||
|
||||
const topGaps = missing.slice(0, MAX_LISTED_GAPS);
|
||||
const remaining = missing.length - topGaps.length;
|
||||
const firstSection = topGaps.length ? PROFILE_FIELD_SECTION[topGaps[0]] : 'personal';
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group wrap="nowrap" gap="md" align="center">
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: completeness, color: 'emaPrimary' }]}
|
||||
label={
|
||||
<Text ta="center" fw={700} size="xs">
|
||||
{completeness}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('profileNudge.title', 'Finish setting up your profile')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('profileNudge.body', {
|
||||
fields: topGaps
|
||||
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
|
||||
.join(', '),
|
||||
defaultValue: 'Still needed: {{fields}}',
|
||||
})}
|
||||
{remaining > 0 &&
|
||||
` ${t('profileNudge.andMore', {
|
||||
count: remaining,
|
||||
defaultValue: 'and {{count}} more',
|
||||
})}`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/profile#${firstSection}`}
|
||||
size="xs"
|
||||
variant="light"
|
||||
>
|
||||
{t('profileNudge.action', 'Complete profile')}
|
||||
</Button>
|
||||
<Tooltip label={t('profileNudge.dismiss', 'Dismiss')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={dismiss}
|
||||
aria-label={t('profileNudge.dismiss', 'Dismiss')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Alert, Anchor, Button, Group, List, Stack, Text } from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
PROFILE_FIELD_SECTION,
|
||||
useCurrentProfile,
|
||||
type ProfileRequirement,
|
||||
} from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ProfileRequirementGateProps {
|
||||
requirement: ProfileRequirement;
|
||||
/** Rendered once the requirement is satisfied. */
|
||||
children: ReactNode;
|
||||
/**
|
||||
* When true the children still render alongside the notice. Use for flows
|
||||
* the applicant can keep working through while a detail is outstanding.
|
||||
*/
|
||||
advisory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for missing profile details in place.
|
||||
*
|
||||
* Deliberately not a redirect. Sending someone to /profile mid-application
|
||||
* loses their work and their place, which is what the old setup wizard did at
|
||||
* a larger scale. This renders an inline card naming exactly which fields are
|
||||
* outstanding and links to the tab that collects them, so the applicant can
|
||||
* fill them in a second tab and come back.
|
||||
*/
|
||||
export function ProfileRequirementGate({
|
||||
requirement,
|
||||
children,
|
||||
advisory = false,
|
||||
}: ProfileRequirementGateProps) {
|
||||
const { t } = useTranslation();
|
||||
const { gapsFor, isLoading } = useCurrentProfile();
|
||||
|
||||
// Never block on the resolver: showing the flow and letting submission fail
|
||||
// is better than a spinner over content that is probably fine.
|
||||
if (isLoading) return <>{children}</>;
|
||||
|
||||
const gaps = gapsFor(requirement);
|
||||
if (gaps.length === 0) return <>{children}</>;
|
||||
|
||||
const sections = [...new Set(gaps.map((field) => PROFILE_FIELD_SECTION[field]))];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
title={t('profileGate.title', {
|
||||
count: gaps.length,
|
||||
defaultValue: 'We need {{count}} more detail before you continue',
|
||||
defaultValue_other: 'We need {{count}} more details before you continue',
|
||||
})}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{requirement.reason}</Text>
|
||||
<List size="sm" spacing={2}>
|
||||
{gaps.map((field) => (
|
||||
<List.Item key={field}>
|
||||
{t(`profileFields.${field}`, { defaultValue: field })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Group gap="xs">
|
||||
{sections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
component={Link}
|
||||
to={`/profile#${section}`}
|
||||
size="xs"
|
||||
variant="light"
|
||||
>
|
||||
{t('profileGate.addDetails', 'Add these details')}
|
||||
</Button>
|
||||
))}
|
||||
<Anchor component={Link} to="/profile" size="xs" c="dimmed">
|
||||
{t('profileGate.viewProfile', 'View full profile')}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
{advisory && children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
type MantineColorScheme,
|
||||
@@ -39,13 +41,14 @@ import {
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
|
||||
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
||||
import type { CurrentProfile } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -62,6 +65,9 @@ import {
|
||||
} from '../components/AddressFormContent';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
/** Tab keys addressable via the URL hash. */
|
||||
const VALID_TABS = ['personal', 'profile', 'address', 'security', 'preferences'];
|
||||
|
||||
function getInitials(name: string, fallback: string) {
|
||||
const source = name?.trim() || fallback?.trim() || '';
|
||||
if (!source) return '?';
|
||||
@@ -84,7 +90,7 @@ export function ProfilePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
@@ -126,8 +132,18 @@ export function ProfilePage() {
|
||||
professions.forEach((p) => { map[p.id] = p.name.en; });
|
||||
return map;
|
||||
}, [professions]);
|
||||
// ---- Profile data (from stored currentProfile) ----
|
||||
const [fetchProfile] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
|
||||
|
||||
// ---- Profile data ----
|
||||
// Resolved through `useCurrentProfile`, which provisions a profile if the
|
||||
// user has none. The page used to read an id out of local storage that only
|
||||
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
||||
// for anyone who signed up after the wizard was removed.
|
||||
const {
|
||||
profile: resolvedProfile,
|
||||
isLoading: profileResolving,
|
||||
completeness,
|
||||
missing,
|
||||
} = useCurrentProfile();
|
||||
const [updateProfile] = useApiMutation<unknown>();
|
||||
const [updateAddress] = useApiMutation<unknown>();
|
||||
|
||||
@@ -136,9 +152,28 @@ export function ProfilePage() {
|
||||
const [profileId, setProfileId] = useState<string | null>(null);
|
||||
const [addressId, setAddressId] = useState<string | null>(null);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const profileFetched = useRef(false);
|
||||
|
||||
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
||||
// requirement gates link straight at them (/profile#address), so the hash
|
||||
// has to select a tab rather than being ignored. Emergency-contact fields
|
||||
// live inside the address form, so both anchors land on that tab.
|
||||
const tabFromHash = useCallback((hash: string) => {
|
||||
const key = hash.replace('#', '');
|
||||
if (key === 'emergency') return 'address';
|
||||
return VALID_TABS.includes(key) ? key : 'personal';
|
||||
}, []);
|
||||
const [activeTab, setActiveTab] = useState(() =>
|
||||
tabFromHash(typeof window === 'undefined' ? '' : window.location.hash),
|
||||
);
|
||||
const { hash } = useLocation();
|
||||
useEffect(() => {
|
||||
setActiveTab(tabFromHash(hash));
|
||||
}, [hash, tabFromHash]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer the freshly resolved profile; fall back to whatever the store
|
||||
// already holds so the form does not flash empty on a refetch.
|
||||
const currentProfile = resolvedProfile ?? storedProfile;
|
||||
if (currentProfile) {
|
||||
setProfileId(currentProfile.id);
|
||||
setLoadedProfile({
|
||||
@@ -170,33 +205,19 @@ export function ProfilePage() {
|
||||
postalAddress: currentProfile.address.postalAddress || '',
|
||||
emergencyContactName: currentProfile.address.emergencyContactName || '',
|
||||
emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
|
||||
emergencyContactRelation: currentProfile.address.emergencycontactRelation || '',
|
||||
// Previously read `emergencycontactRelation` (lower-case c), so the
|
||||
// saved relationship never appeared when reopening the profile.
|
||||
emergencyContactRelation:
|
||||
currentProfile.address.emergencyContactRelation || '',
|
||||
});
|
||||
}
|
||||
setDataLoading(false);
|
||||
} else if (user && !profileFetched.current) {
|
||||
profileFetched.current = true;
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (profileId) {
|
||||
const q = `w=user_id:=:${user.id}&i=user,address,profession`;
|
||||
fetchProfile({ url: `/profiles?q=${encodeURIComponent(q)}`, method: 'GET' })
|
||||
.unwrap()
|
||||
.then((result) => {
|
||||
if (result.total > 0 && result.items.length > 0) {
|
||||
const profile = result.items[0];
|
||||
dispatch(setCurrentProfile(profile));
|
||||
} else {
|
||||
setDataLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => setDataLoading(false));
|
||||
} else {
|
||||
setDataLoading(false);
|
||||
}
|
||||
} else {
|
||||
} else if (!profileResolving) {
|
||||
// Resolver finished and there is still nothing — render the empty form
|
||||
// rather than an indefinite spinner.
|
||||
setDataLoading(false);
|
||||
}
|
||||
}, [currentProfile, user, fetchProfile, dispatch]);
|
||||
}, [resolvedProfile, storedProfile, profileResolving]);
|
||||
|
||||
// Load the latest user from the server on mount
|
||||
useEffect(() => {
|
||||
@@ -206,7 +227,9 @@ export function ProfilePage() {
|
||||
.then((me) => {
|
||||
if (active) dispatch(setUser(me));
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Best-effort refresh; the store already holds the user from sign-in.
|
||||
});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -446,12 +469,50 @@ export function ProfilePage() {
|
||||
{user.username}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Completeness. Informational only — nothing here blocks the user,
|
||||
it just makes visible what the nudge and the in-flow gates are
|
||||
reacting to. Computed by the API so all three agree. */}
|
||||
<Tooltip
|
||||
label={
|
||||
missing.length
|
||||
? missing
|
||||
.map((field) => t(`profileFields.${field}`, { defaultValue: field }))
|
||||
.join(', ')
|
||||
: t('profileSections.sectionSaved', 'Saved')
|
||||
}
|
||||
multiline
|
||||
w={260}
|
||||
withArrow
|
||||
>
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: completeness, color: 'emaPrimary' }]}
|
||||
aria-label={t('profileSections.completeness', {
|
||||
value: completeness,
|
||||
defaultValue: '{{value}}% complete',
|
||||
})}
|
||||
label={
|
||||
<Text ta="center" fw={700} size="xs">
|
||||
{completeness}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
defaultValue="personal"
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
const next = value ?? 'personal';
|
||||
setActiveTab(next);
|
||||
// Keep the URL shareable without pushing a history entry per tab.
|
||||
window.history.replaceState(null, '', `#${next}`);
|
||||
}}
|
||||
variant="pills"
|
||||
classNames={{ list: classes.list, tab: classes.tab }}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,446 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBook2,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconPrinter,
|
||||
IconShield,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify } from "@ema-platform/ui";
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — replace with real API
|
||||
// ---------------------------------------------------------------------------
|
||||
const ELIGIBILITY = {
|
||||
hasProfile: true,
|
||||
hasNationalId: true,
|
||||
hasMedicalCert: true,
|
||||
medicalExpiry: "2026-03-14",
|
||||
bstComplete: true,
|
||||
bstItems: [
|
||||
{ label: "Personal Survival Techniques (PST)", done: true },
|
||||
{ label: "Fire Prevention & Fire Fighting (FPFF)", done: true },
|
||||
{ label: "Elementary First Aid (EFA)", done: true },
|
||||
{ label: "Personal Safety & Social Responsibility (PSSR)", done: true },
|
||||
{ label: "Sexual Harassment Prevention", done: true },
|
||||
],
|
||||
};
|
||||
|
||||
// const MOCK_APPLICATION: SeamanBookApp | null = null;
|
||||
const MOCK_APPLICATION: SeamanBookApp = {
|
||||
id: "SB-2026-001",
|
||||
submittedAt: "2026-07-02T09:15:00Z",
|
||||
status: "Approved",
|
||||
remarks: "Seaman Book issued successfully.",
|
||||
timeline: [
|
||||
{
|
||||
date: "2026-07-02T09:15:00Z",
|
||||
event: "Application Submitted",
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
date: "2026-07-03T11:30:00Z",
|
||||
event: "Document Verification",
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
date: "2026-07-04T14:10:00Z",
|
||||
event: "Application Reviewed",
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
date: "2026-07-05T10:00:00Z",
|
||||
event: "Seaman Book Issued",
|
||||
done: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
interface SeamanBookApp {
|
||||
id: string;
|
||||
submittedAt: string;
|
||||
status: string;
|
||||
remarks: string;
|
||||
timeline: { date: string | null; event: string; done: boolean }[];
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
"Under Review": "yellow",
|
||||
Approved: "teal",
|
||||
Rejected: "red",
|
||||
"Correction Required": "orange",
|
||||
"Ready for Collection": "blue",
|
||||
};
|
||||
|
||||
function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon
|
||||
size={22}
|
||||
radius="xl"
|
||||
variant={ok ? "filled" : "light"}
|
||||
color={ok ? "teal" : "red"}
|
||||
>
|
||||
{ok ? <IconCheck size={13} /> : <IconX size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c={ok ? undefined : "dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function SeamanBookPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(!!MOCK_APPLICATION);
|
||||
|
||||
const bstDone = ELIGIBILITY.bstItems.filter((b) => b.done).length;
|
||||
const isEligible =
|
||||
ELIGIBILITY.hasProfile &&
|
||||
ELIGIBILITY.hasNationalId &&
|
||||
ELIGIBILITY.hasMedicalCert &&
|
||||
ELIGIBILITY.bstComplete;
|
||||
|
||||
const handleApply = async () => {
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
setSubmitting(false);
|
||||
setSubmitted(true);
|
||||
notify.success(
|
||||
"Seaman Book application submitted successfully! Reference: SB-APP-2024-002",
|
||||
);
|
||||
};
|
||||
|
||||
const activeStep = MOCK_APPLICATION
|
||||
? MOCK_APPLICATION.timeline.filter((t) => t.done).length - 1
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>My Application — Seaman Book & BTC</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
A Seaman Book is your official maritime identity document. It records
|
||||
your sea service and must be held before joining any vessel.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Active application status */}
|
||||
{MOCK_APPLICATION && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Application {MOCK_APPLICATION.id}</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Submitted {MOCK_APPLICATION.submittedAt}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[MOCK_APPLICATION.status] ?? "gray"}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{MOCK_APPLICATION.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{MOCK_APPLICATION.remarks && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={15} />}
|
||||
mb="md"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="sm">{MOCK_APPLICATION.remarks}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Progress stepper */}
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{MOCK_APPLICATION.timeline.map((step, i) => (
|
||||
<Stepper.Step
|
||||
key={i}
|
||||
label={step.event}
|
||||
description={step.date ?? "Pending"}
|
||||
icon={
|
||||
step.done ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{MOCK_APPLICATION.status === "Ready for Collection" && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconPrinter size={17} />}
|
||||
mt="md"
|
||||
>
|
||||
Your Seaman Book is ready. Please visit the EMA office to collect
|
||||
it. Bring your National ID.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* No active application — eligibility + apply */}
|
||||
{!submitted && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Eligibility checklist */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isEligible ? "teal" : "orange"}
|
||||
size={36}
|
||||
radius="md"
|
||||
>
|
||||
<IconShield size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Eligibility Requirements</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<EligibilityItem
|
||||
label="Profile completed (name, DOB, nationality)"
|
||||
ok={ELIGIBILITY.hasProfile}
|
||||
/>
|
||||
<EligibilityItem
|
||||
label="National ID / Fayda uploaded"
|
||||
ok={ELIGIBILITY.hasNationalId}
|
||||
/>
|
||||
<EligibilityItem
|
||||
label="Valid medical certificate uploaded"
|
||||
ok={ELIGIBILITY.hasMedicalCert}
|
||||
/>
|
||||
|
||||
<Divider
|
||||
label="Basic Safety Training (all 5 required)"
|
||||
labelPosition="left"
|
||||
my={4}
|
||||
/>
|
||||
{ELIGIBILITY.bstItems.map((item) => (
|
||||
<EligibilityItem
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
ok={item.done}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isEligible && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertCircle size={15} />}
|
||||
mt="xs"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="xs">
|
||||
Complete all requirements above before applying. Missing
|
||||
BST: {5 - bstDone} certificate(s).
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isEligible && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconCircleCheck size={15} />}
|
||||
mt="xs"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="xs">
|
||||
You meet all requirements. You may proceed with your
|
||||
application.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Application form */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconFileDescription size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>New Application</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz="sm" c="dimmed" lh={1.6}>
|
||||
Upon submitting your application, EMA Registration Officers will
|
||||
verify your profile, documents, medical certificate, and Basic
|
||||
Safety Training certificates. You will be notified at each stage
|
||||
by email and SMS.
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text fw={600} fz="sm">
|
||||
What will be verified:
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
"Full seafarer profile",
|
||||
"National ID / Fayda authenticity",
|
||||
"Medical certificate validity",
|
||||
"All 5 Basic Safety Training certificates",
|
||||
"Passport size photo",
|
||||
].map((item) => (
|
||||
<Group key={item} gap="xs">
|
||||
<IconCircleCheck
|
||||
size={15}
|
||||
color="var(--mantine-color-teal-6)"
|
||||
/>
|
||||
<Text fz="sm">{item}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconClock size={15} color="var(--mantine-color-blue-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Processing time
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
5–7 working days
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder radius="sm" p="sm">
|
||||
<Group gap="xs">
|
||||
<IconHeart size={15} color="var(--mantine-color-red-6)" />
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Medical validity
|
||||
</Text>
|
||||
<Text fz="sm" fw={600}>
|
||||
2 years (STCW)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="xs">
|
||||
Application fee will be communicated during the review
|
||||
process. Payment can be made online or at the EMA office.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconBook2 size={16} />}
|
||||
onClick={() => navigate("/seaman-book/apply")}
|
||||
loading={submitting}
|
||||
disabled={!isEligible}
|
||||
size="md"
|
||||
>
|
||||
Start Application
|
||||
</Button>
|
||||
|
||||
{!isEligible && (
|
||||
<Text fz="xs" c="dimmed" ta="center">
|
||||
Complete all eligibility requirements to enable this button.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<Paper withBorder radius="lg" p="lg" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconInfoCircle size={18} color="var(--mantine-color-blue-6)" />
|
||||
<Text fw={700} fz="sm">
|
||||
About the Seaman Book
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{[
|
||||
{
|
||||
icon: IconBook2,
|
||||
title: "Official Identity",
|
||||
desc: "Internationally recognized maritime identity document required before joining any vessel.",
|
||||
},
|
||||
{
|
||||
icon: IconFileDescription,
|
||||
title: "Service Record",
|
||||
desc: "Records all your sea service, vessel assignments, and employment history.",
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
title: "STCW Compliance",
|
||||
desc: "Required under STCW for all seafarers. Must be renewed and kept valid throughout your career.",
|
||||
},
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<Box key={title}>
|
||||
<Group gap="xs" mb={4}>
|
||||
<Icon size={16} color="var(--mantine-color-blue-6)" />
|
||||
<Text fz="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.5}>
|
||||
{desc}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Seaman Book"
|
||||
description="Seaman Book applications are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeamanBookPage;
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { AmharicDatePicker, toGregorianDateLabel } from '../../../components/AmharicDatePicker';
|
||||
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)} />
|
||||
<AmharicDatePicker label="Agreement Start Date" required value={agreementStartDate} onChange={setAgreementStartDate} />
|
||||
<AmharicDatePicker label="Agreement End Date" required value={agreementEndDate} onChange={setAgreementEndDate} />
|
||||
</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 ? toGregorianDateLabel(agreementStartDate) : ''} />
|
||||
<ReviewRow label="Agreement End Date" value={agreementEndDate ? toGregorianDateLabel(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>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,21 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// Sample mock — in production this comes from the API
|
||||
const MOCK_MY_VESSELS = [
|
||||
{
|
||||
id: 'VR-2024-001',
|
||||
vesselName: 'Lake Tana Star',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Passenger Ferry',
|
||||
status: 'Under Review',
|
||||
submittedDate: '2024-03-15',
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray', 'Under Review': 'yellow', Approved: 'teal', Rejected: 'red', 'Correction Required': 'orange',
|
||||
};
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnerDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="blue" variant="light">
|
||||
<IconShip size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>My Vessels</Title>
|
||||
<Text fz="sm" c="dimmed">Manage your vessel registrations</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button leftSection={<IconAnchor size={16} />} onClick={() => navigate('/vessel-registration/apply')}>
|
||||
Register New Vessel
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{MOCK_MY_VESSELS.length === 0 ? (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size={56} radius="xl" color="blue" variant="light">
|
||||
<IconAnchor size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={4} ta="center">No Vessels Registered</Title>
|
||||
<Text fz="sm" c="dimmed" ta="center" maw={400}>
|
||||
You haven't registered any vessels yet. Click "Register New Vessel" to begin the application process.
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/vessel-registration/apply')}>Start Registration</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{MOCK_MY_VESSELS.map((v) => (
|
||||
<Card key={v.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light">
|
||||
<IconShip size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{v.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{v.id}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text fz="xs" fw={600} c={`${STATUS_COLOR[v.status]}.6`}>{v.status}</Text>
|
||||
</Group>
|
||||
<Stack gap={4} mt="sm">
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" c="dimmed">Category:</Text>
|
||||
<Text fz="xs">{v.category}</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" c="dimmed">Type:</Text>
|
||||
<Text fz="xs">{v.vesselType}</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Text fz="xs" c="dimmed">Submitted:</Text>
|
||||
<Text fz="xs">{v.submittedDate}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Button size="xs" variant="light" fullWidth mt="sm" onClick={() => navigate('/vessel-registration')}>
|
||||
View Details
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
Vessel registration is valid for <strong>5 years</strong> from the approval date. You will be notified when renewal is due.
|
||||
</Alert>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Vessel owner dashboard"
|
||||
description="Vessel registration is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnerDashboardPage;
|
||||
|
||||
@@ -1,440 +1,21 @@
|
||||
import { useEffect, 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,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconFileDescription,
|
||||
IconInfoCircle,
|
||||
IconTransferIn,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
// Minimal vessel type for the approved vessel list
|
||||
interface ApprovedVessel {
|
||||
id: string;
|
||||
vesselName: string;
|
||||
category: string;
|
||||
vesselType: string;
|
||||
ownerName: string;
|
||||
ownerNationalIdOrTin: string;
|
||||
ownerPhone: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const MOCK_APPROVED_VESSELS: ApprovedVessel[] = [
|
||||
{
|
||||
id: 'VR-2024-001',
|
||||
vesselName: 'Lake Tana Star',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Passenger Ferry',
|
||||
ownerName: 'Abebe Girma',
|
||||
ownerNationalIdOrTin: 'ET-9812345',
|
||||
ownerPhone: '+251 911 234 567',
|
||||
status: 'Approved',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected';
|
||||
|
||||
export interface OwnershipTransferRequest {
|
||||
id: string;
|
||||
vesselId: string;
|
||||
vesselName: string;
|
||||
category: string;
|
||||
vesselType: string;
|
||||
currentOwnerName: string;
|
||||
currentOwnerIdOrTin: string;
|
||||
currentOwnerPhone: string;
|
||||
newOwnerName: string;
|
||||
newOwnerIdOrTin: string;
|
||||
newOwnerPhone: string;
|
||||
newOwnerEmail: string;
|
||||
newOwnerAddress: string;
|
||||
transferReason: string;
|
||||
remarks: string;
|
||||
status: TransferStatus;
|
||||
submittedDate: string;
|
||||
approvalDate: string | null;
|
||||
}
|
||||
|
||||
export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [
|
||||
{
|
||||
id: 'OT-2024-001',
|
||||
vesselId: 'VR-2024-001',
|
||||
vesselName: 'Lake Tana Star',
|
||||
category: 'Inland Waterway Vessel',
|
||||
vesselType: 'Passenger Ferry',
|
||||
currentOwnerName: 'Abebe Girma',
|
||||
currentOwnerIdOrTin: 'ET-9812345',
|
||||
currentOwnerPhone: '+251 911 234 567',
|
||||
newOwnerName: 'Tigist Haile',
|
||||
newOwnerIdOrTin: 'ET-7743210',
|
||||
newOwnerPhone: '+251 922 876 543',
|
||||
newOwnerEmail: 'tigist.haile@email.com',
|
||||
newOwnerAddress: 'Bahir Dar, Amhara Region',
|
||||
transferReason: 'Sale',
|
||||
remarks: 'Vessel sold to new owner. Bill of sale attached.',
|
||||
status: 'Pending',
|
||||
submittedDate: '2024-06-01',
|
||||
approvalDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
const TRANSFER_REASONS = [
|
||||
'Sale / Purchase',
|
||||
'Inheritance',
|
||||
'Gift / Donation',
|
||||
'Corporate Restructuring',
|
||||
'Court Order',
|
||||
'Other',
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'yellow',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transfer request card
|
||||
// ---------------------------------------------------------------------------
|
||||
function TransferCard({ req }: { req: OwnershipTransferRequest }) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="violet" variant="light">
|
||||
<IconTransferIn size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{req.vesselName}</Text>
|
||||
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
|
||||
</Group>
|
||||
<Divider my="xs" />
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{[
|
||||
{ label: 'From', value: req.currentOwnerName },
|
||||
{ label: 'To', value: req.newOwnerName },
|
||||
{ label: 'Reason', value: req.transferReason },
|
||||
{ label: 'Submitted', value: req.submittedDate },
|
||||
].map((r) => (
|
||||
<div key={r.label}>
|
||||
<Text fz="xs" c="dimmed">{r.label}</Text>
|
||||
<Text fz="sm" fw={500}>{r.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{req.status === 'Approved' && (
|
||||
<Alert icon={<IconCircleCheck size={14} />} color="teal" mt="sm" py="xs">
|
||||
Transfer approved on {req.approvalDate}. New certificates issued to {req.newOwnerName}.
|
||||
</Alert>
|
||||
)}
|
||||
{req.status === 'Rejected' && req.remarks && (
|
||||
<Alert icon={<IconAlertCircle size={14} />} color="red" mt="sm" py="xs">
|
||||
Rejected: {req.remarks}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function OwnershipTransferPage() {
|
||||
const navigate = useNavigate();
|
||||
const [myVessels, setMyVessels] = useState<ApprovedVessel[]>([]);
|
||||
const [transfers, setTransfers] = useState<OwnershipTransferRequest[]>(MOCK_TRANSFER_REQUESTS);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchTrigger] = useApiMutation<ApprovedVessel[]>();
|
||||
const [submitTrigger] = useApiMutation<{ id: string }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
// Form state
|
||||
const [selectedVesselId, setSelectedVesselId] = useState<string | null>(null);
|
||||
const [newOwnerName, setNewOwnerName] = useState('');
|
||||
const [newOwnerIdOrTin, setNewOwnerIdOrTin] = useState('');
|
||||
const [newOwnerPhone, setNewOwnerPhone] = useState('');
|
||||
const [newOwnerEmail, setNewOwnerEmail] = useState('');
|
||||
const [newOwnerAddress, setNewOwnerAddress] = useState('');
|
||||
const [transferReason, setTransferReason] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [billOfSale, setBillOfSale] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: '/vessel-registrations/my', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => setMyVessels(Array.isArray(data) ? data : [data]))
|
||||
.catch(() => {
|
||||
// Fall back to mock approved vessels
|
||||
setMyVessels(MOCK_APPROVED_VESSELS);
|
||||
});
|
||||
}, [fetchTrigger]);
|
||||
|
||||
const selectedVessel = myVessels.find((v) => v.id === selectedVesselId) ?? null;
|
||||
|
||||
const canSubmit = !!selectedVesselId && !!newOwnerName.trim() && !!newOwnerIdOrTin.trim() &&
|
||||
!!newOwnerPhone.trim() && !!transferReason && !!billOfSale;
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedVesselId(null);
|
||||
setNewOwnerName('');
|
||||
setNewOwnerIdOrTin('');
|
||||
setNewOwnerPhone('');
|
||||
setNewOwnerEmail('');
|
||||
setNewOwnerAddress('');
|
||||
setTransferReason(null);
|
||||
setNotes('');
|
||||
setBillOfSale(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedVessel) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTrigger({
|
||||
url: '/vessel-ownership-transfers',
|
||||
method: 'POST',
|
||||
body: {
|
||||
vesselId: selectedVessel.id,
|
||||
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail,
|
||||
newOwnerAddress, transferReason, notes,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
// Optimistic local update
|
||||
const newReq: OwnershipTransferRequest = {
|
||||
id: `OT-${Date.now()}`,
|
||||
vesselId: selectedVessel.id,
|
||||
vesselName: selectedVessel.vesselName,
|
||||
category: selectedVessel.category,
|
||||
vesselType: selectedVessel.vesselType,
|
||||
currentOwnerName: selectedVessel.ownerName,
|
||||
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
|
||||
currentOwnerPhone: selectedVessel.ownerPhone,
|
||||
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
|
||||
transferReason: transferReason ?? '',
|
||||
remarks: notes,
|
||||
status: 'Pending',
|
||||
submittedDate: new Date().toISOString().split('T')[0],
|
||||
approvalDate: null,
|
||||
};
|
||||
setTransfers((prev) => [newReq, ...prev]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
notify.success('Ownership transfer request submitted successfully.');
|
||||
} catch {
|
||||
// Still add optimistically on API error (mock mode)
|
||||
const newReq: OwnershipTransferRequest = {
|
||||
id: `OT-${Date.now()}`,
|
||||
vesselId: selectedVessel.id,
|
||||
vesselName: selectedVessel.vesselName,
|
||||
category: selectedVessel.category,
|
||||
vesselType: selectedVessel.vesselType,
|
||||
currentOwnerName: selectedVessel.ownerName,
|
||||
currentOwnerIdOrTin: selectedVessel.ownerNationalIdOrTin,
|
||||
currentOwnerPhone: selectedVessel.ownerPhone,
|
||||
newOwnerName, newOwnerIdOrTin, newOwnerPhone, newOwnerEmail, newOwnerAddress,
|
||||
transferReason: transferReason ?? '',
|
||||
remarks: notes,
|
||||
status: 'Pending',
|
||||
submittedDate: new Date().toISOString().split('T')[0],
|
||||
approvalDate: null,
|
||||
};
|
||||
setTransfers((prev) => [newReq, ...prev]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
notify.success('Ownership transfer request submitted.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const vesselOptions = myVessels
|
||||
.filter((v) => v.status === 'Approved')
|
||||
.map((v) => ({ value: v.id, label: `${v.vesselName} (${v.id})` }));
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" color="violet" variant="light">
|
||||
<IconTransferIn size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>Ownership Transfer</Title>
|
||||
<Text fz="sm" c="dimmed">Request transfer of vessel ownership to another party</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconTransferIn size={16} />}
|
||||
color="violet"
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={vesselOptions.length === 0}
|
||||
>
|
||||
Request Transfer
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{vesselOptions.length === 0 && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
You must have at least one <strong>approved</strong> vessel registration to request an ownership transfer.{' '}
|
||||
<Text span fz="sm" c="blue.6" style={{ cursor: 'pointer' }} onClick={() => navigate('/vessel-registration')}>
|
||||
View my registrations →
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* How it works */}
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-violet-light)">
|
||||
<Group gap="xs" mb="sm">
|
||||
<IconInfoCircle size={16} color="var(--mantine-color-violet-7)" />
|
||||
<Text fw={600} fz="sm" c="violet.7">How Ownership Transfer Works</Text>
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
{[
|
||||
"Submit a transfer request with the new owner's details and a Bill of Sale",
|
||||
'The Maritime Authority reviews and verifies the transfer documents',
|
||||
'Upon approval, ownership is officially transferred in the registry',
|
||||
'New certificates are automatically generated for the new owner',
|
||||
'The new owner receives: Certificate of Nationality, Certificate of Ownership, Certificate of Registration (sea-going) or Inland Registration Certificate (inland)',
|
||||
].map((step, i) => (
|
||||
<Group key={i} gap="xs" align="flex-start">
|
||||
<ThemeIcon size={20} radius="xl" color="violet" variant="light" style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Text fz="xs" fw={700}>{i + 1}</Text>
|
||||
</ThemeIcon>
|
||||
<Text fz="sm">{step}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Existing transfer requests */}
|
||||
<div>
|
||||
<Text fw={700} fz="sm" mb="sm">My Transfer Requests</Text>
|
||||
{transfers.length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Text fz="sm" c="dimmed" ta="center">No transfer requests submitted yet.</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{transfers.map((req) => <TransferCard key={req.id} req={req} />)}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer request modal */}
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => { setModalOpen(false); resetForm(); }}
|
||||
title="Request Ownership Transfer"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert icon={<IconAlertCircle size={15} />} color="orange" variant="light">
|
||||
Ownership transfer is permanent. Ensure all details are correct before submitting.
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label="Select Vessel"
|
||||
placeholder="Choose an approved vessel"
|
||||
required
|
||||
data={vesselOptions}
|
||||
value={selectedVesselId}
|
||||
onChange={setSelectedVesselId}
|
||||
/>
|
||||
|
||||
{selectedVessel && (
|
||||
<Paper withBorder radius="sm" p="sm" bg="var(--mantine-color-gray-0)">
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb={4}>Current Owner</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<div><Text fz="xs" c="dimmed">Name</Text><Text fz="sm">{selectedVessel.ownerName}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">ID / TIN</Text><Text fz="sm">{selectedVessel.ownerNationalIdOrTin}</Text></div>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Divider label="New Owner Details" labelPosition="center" />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="New Owner Full Name / Company" placeholder="e.g. Tigist Haile" required value={newOwnerName} onChange={(e) => setNewOwnerName(e.currentTarget.value)} leftSection={<IconUser size={15} />} />
|
||||
<TextInput label="National ID / TIN" placeholder="e.g. ET-0000000" required value={newOwnerIdOrTin} onChange={(e) => setNewOwnerIdOrTin(e.currentTarget.value)} />
|
||||
<TextInput label="Phone Number" placeholder="+251 9XX XXX XXX" required value={newOwnerPhone} onChange={(e) => setNewOwnerPhone(e.currentTarget.value)} />
|
||||
<TextInput label="Email Address" placeholder="owner@example.com" value={newOwnerEmail} onChange={(e) => setNewOwnerEmail(e.currentTarget.value)} />
|
||||
<TextInput label="Address" placeholder="City, Region" value={newOwnerAddress} onChange={(e) => setNewOwnerAddress(e.currentTarget.value)} />
|
||||
<Select label="Reason for Transfer" placeholder="Select reason" required data={TRANSFER_REASONS} value={transferReason} onChange={setTransferReason} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Supporting Document" labelPosition="center" />
|
||||
|
||||
{/* Bill of Sale upload */}
|
||||
<Card withBorder radius="md" p="md" style={{ borderStyle: 'dashed', borderColor: billOfSale ? '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-violet-light)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<IconFileDescription size={22} color="var(--mantine-color-violet-6)" />
|
||||
</Box>
|
||||
<div>
|
||||
<Text fw={600} fz="sm">Bill of Sale / Transfer Document <Text span c="red">*</Text></Text>
|
||||
<Text fz="xs" c="dimmed">Legal document confirming the transfer of ownership</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{billOfSale ? (
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={16} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{billOfSale.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => setBillOfSale(null)}>Remove</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<FileButton onChange={setBillOfSale} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => <Button size="xs" variant="default" {...props}>Choose File</Button>}
|
||||
</FileButton>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Textarea label="Additional Notes" placeholder="Any additional information for the authority..." value={notes} onChange={(e) => setNotes(e.currentTarget.value)} rows={3} />
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => { setModalOpen(false); resetForm(); }}>Cancel</Button>
|
||||
<Button color="violet" disabled={!canSubmit} loading={submitting} leftSection={<IconArrowRight size={15} />} onClick={handleSubmit}>
|
||||
Submit Transfer Request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default OwnershipTransferPage;
|
||||
|
||||
@@ -27,9 +27,8 @@ import {
|
||||
IconInfoCircle,
|
||||
IconClockHour4,
|
||||
IconTransferIn,
|
||||
} from "@tabler/icons-react";
|
||||
import { useApiMutation } from "@ema-platform/api";
|
||||
import { authStorage } from "@ema-platform/auth";
|
||||
} from '@tabler/icons-react';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -186,9 +185,12 @@ export function VesselRegistrationPage() {
|
||||
const [fetchTrigger] = useApiMutation<VesselRegistration>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
// `/vessel-registrations/my` resolves the owner from the token, so it never
|
||||
// needed a profile id. Gating on one meant anyone who signed up after the
|
||||
// setup wizard was removed — and so had nothing in local storage — silently
|
||||
// never loaded their registration.
|
||||
useEffect(() => {
|
||||
const profileId = authStorage.getProfileId();
|
||||
if (!profileId || fetched.current) return;
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
fetchTrigger({ url: "/vessel-registrations/my", method: "GET" })
|
||||
.unwrap()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user