mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 18:55:43 +00:00
Adding license generation functionality
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user