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