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