Adding license generation functionality

This commit is contained in:
Muluhabt
2026-07-29 16:53:50 +03:00
parent 26cc1f4fd2
commit 5161e278ce
102 changed files with 5995 additions and 21326 deletions

View File

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