mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 13:28:13 +00:00
frontend scalfolding
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, useMantineTheme, useComputedColorScheme } from '@mantine/core';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Application } from '../../licenses/types/license.types';
|
||||
|
||||
const MONTHS_BACK = 6;
|
||||
|
||||
export function ApplicationsTrend({ applications }: { applications: Application[] }) {
|
||||
const theme = useMantineTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const scheme = useComputedColorScheme('light');
|
||||
const locale = i18n.language === 'am' ? 'am-ET' : 'en-GB';
|
||||
|
||||
const accent = theme.colors.emaPrimary[6];
|
||||
const gridColor = scheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2];
|
||||
const axisColor = scheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6];
|
||||
|
||||
const data = useMemo(() => {
|
||||
const now = new Date();
|
||||
const buckets: { key: string; label: string; count: number }[] = [];
|
||||
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
buckets.push({
|
||||
key: `${d.getFullYear()}-${d.getMonth()}`,
|
||||
label: d.toLocaleDateString(locale, { month: 'short' }),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
const index = new Map(buckets.map((b, i) => [b.key, i]));
|
||||
applications.forEach((a) => {
|
||||
const d = new Date(a.submittedAt);
|
||||
const k = `${d.getFullYear()}-${d.getMonth()}`;
|
||||
const i = index.get(k);
|
||||
if (i !== undefined) buckets[i].count += 1;
|
||||
});
|
||||
return buckets;
|
||||
}, [applications, locale]);
|
||||
|
||||
return (
|
||||
<Box h={240}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 10, right: 8, left: -18, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ema-trend" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12, fill: axisColor }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: accent, strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
boxShadow: theme.shadows.md,
|
||||
background: scheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
color: scheme === 'dark' ? 'white' : 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={accent}
|
||||
strokeWidth={2.5}
|
||||
fill="url(#ema-trend)"
|
||||
dot={{ r: 3, fill: accent, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Box, Button, Group, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconFilePlus, IconCategory, IconAnchor } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DashboardHero({ name }: { name?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={{ base: 'lg', sm: 'xl' }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'var(--mantine-radius-lg)',
|
||||
background: 'linear-gradient(135deg, #2453a2 0%, #3160b7 45%, #1fc29d 130%)',
|
||||
color: 'white',
|
||||
boxShadow: 'var(--mantine-shadow-md)',
|
||||
}}
|
||||
>
|
||||
{/* decorative wash */}
|
||||
<IconAnchor
|
||||
size={220}
|
||||
stroke={1}
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -30,
|
||||
bottom: -50,
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -60,
|
||||
right: 120,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack gap="md" style={{ position: 'relative', maxWidth: 620 }}>
|
||||
<Stack gap={4}>
|
||||
<Text fz="sm" fw={600} style={{ opacity: 0.85 }}>
|
||||
{t('dashboard.hero.greeting')}
|
||||
{name ? ',' : ''}
|
||||
</Text>
|
||||
{name && (
|
||||
<Title order={2} c="white">
|
||||
{name}
|
||||
</Title>
|
||||
)}
|
||||
<Text fz={{ base: 'sm', sm: 'md' }} style={{ opacity: 0.92 }} maw={520}>
|
||||
{t('dashboard.hero.subtitle')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="white"
|
||||
c="emaPrimary.7"
|
||||
leftSection={<IconFilePlus size={18} />}
|
||||
onClick={() => navigate('/apply')}
|
||||
>
|
||||
{t('dashboard.newApplication')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconCategory size={18} />}
|
||||
onClick={() => navigate('/services')}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'rgba(255,255,255,0.16)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('dashboard.hero.browse')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Center, Group, Stack, Text } from '@mantine/core';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
import { useMantineTheme } from '@mantine/core';
|
||||
import type { Application, ApplicationStatus } from '../../licenses/types/license.types';
|
||||
import { APPLICATION_STATUS_COLORS } from '../../licenses/constants';
|
||||
import { useLicenseLabels } from '../../licenses/hooks/useLicenseLabels';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Resolve a Mantine color name + shade to its hex via the theme. */
|
||||
function useColorResolver() {
|
||||
const theme = useMantineTheme();
|
||||
return (name: string, shade = 6) => theme.colors[name]?.[shade] ?? theme.colors.gray[6];
|
||||
}
|
||||
|
||||
export function StatusDonut({ applications }: { applications: Application[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { applicationStatus } = useLicenseLabels();
|
||||
const resolve = useColorResolver();
|
||||
|
||||
const data = useMemo(() => {
|
||||
const counts = applications.reduce<Record<string, number>>((acc, a) => {
|
||||
acc[a.status] = (acc[a.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.entries(counts).map(([status, value]) => ({
|
||||
status: status as ApplicationStatus,
|
||||
value,
|
||||
color: resolve(APPLICATION_STATUS_COLORS[status as ApplicationStatus]),
|
||||
}));
|
||||
}, [applications, resolve]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Center h={220}>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t('dashboard.charts.noData')}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Box w={180} h={180} style={{ position: 'relative' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="status"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={58}
|
||||
outerRadius={84}
|
||||
paddingAngle={2}
|
||||
stroke="none"
|
||||
>
|
||||
{data.map((d) => (
|
||||
<Cell key={d.status} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Text fw={800} fz={28} lh={1}>
|
||||
{applications.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.total')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap="xs" flex={1}>
|
||||
{data.map((d) => (
|
||||
<Group key={d.status} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Box w={10} h={10} style={{ borderRadius: 3, background: d.color }} />
|
||||
<Text size="sm">{applicationStatus(d.status)}</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{d.value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,197 @@
|
||||
import { Stack, Title, Text, Paper } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCertificate,
|
||||
IconClockHour4,
|
||||
IconFilePlus,
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '../../licenses/components/StatCard';
|
||||
import { ApplicationsTable } from '../../licenses/components/ApplicationsTable';
|
||||
import { useLicenses } from '../../licenses/hooks/useLicenses';
|
||||
import { DashboardHero } from '../components/DashboardHero';
|
||||
import { StatusDonut } from '../components/StatusDonut';
|
||||
import { ApplicationsTrend } from '../components/ApplicationsTrend';
|
||||
import type { ApplicationStatus } from '../../licenses/types/license.types';
|
||||
|
||||
const OPEN_STATUSES: ApplicationStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'INFO_REQUESTED',
|
||||
'PAYMENT_PENDING',
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { applications, licenses } = useLicenses();
|
||||
|
||||
// Brief skeleton on the charts for a polished first paint.
|
||||
const [chartsLoading, setChartsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setChartsLoading(false), 550);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE').length;
|
||||
const pending = applications.filter((a) => OPEN_STATUSES.includes(a.status)).length;
|
||||
const expiring = licenses.filter((l) => l.status === 'EXPIRING_SOON').length;
|
||||
const actionRequired = applications.filter((a) => a.status === 'INFO_REQUESTED').length;
|
||||
|
||||
const recent = applications.slice(0, 5);
|
||||
const holderName = licenses[0]?.holderName;
|
||||
|
||||
const quickActions: {
|
||||
icon: Icon;
|
||||
title: string;
|
||||
desc: string;
|
||||
to: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
icon: IconFilePlus,
|
||||
title: t('dashboard.quick.apply'),
|
||||
desc: t('dashboard.quick.applyDesc'),
|
||||
to: '/apply',
|
||||
color: 'emaPrimary',
|
||||
},
|
||||
{
|
||||
icon: IconRefresh,
|
||||
title: t('dashboard.quick.renew'),
|
||||
desc: t('dashboard.quick.renewDesc'),
|
||||
to: '/licenses',
|
||||
color: 'emaTeal',
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
title: t('dashboard.quick.track'),
|
||||
desc: t('dashboard.quick.trackDesc'),
|
||||
to: '/applications',
|
||||
color: 'indigo',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>My Dashboard</Title>
|
||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
||||
<Text c="dimmed">
|
||||
Welcome to the EMA Portal. Your content will appear here.
|
||||
</Text>
|
||||
<DashboardHero name={holderName} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard
|
||||
label={t('dashboard.stats.activeLicenses')}
|
||||
value={activeLicenses}
|
||||
icon={IconCertificate}
|
||||
color="teal"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.pendingApplications')}
|
||||
value={pending}
|
||||
icon={IconClockHour4}
|
||||
color="indigo"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.expiringSoon')}
|
||||
value={expiring}
|
||||
icon={IconAlertTriangle}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.stats.actionRequired')}
|
||||
value={actionRequired}
|
||||
icon={IconAlertTriangle}
|
||||
color="red"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
|
||||
{quickActions.map((qa) => (
|
||||
<UnstyledButton key={qa.to} onClick={() => navigate(qa.to)}>
|
||||
<Card withBorder padding="lg" h="100%" className="ema-hover-lift">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={qa.color} size={42}>
|
||||
<qa.icon size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600}>{qa.title}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{qa.desc}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<IconArrowRight size={18} style={{ opacity: 0.5 }} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{t('dashboard.charts.trend')}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('dashboard.charts.trendPeriod')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{chartsLoading ? (
|
||||
<Skeleton height={240} radius="md" />
|
||||
) : (
|
||||
<ApplicationsTrend applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
{t('dashboard.charts.distribution')}
|
||||
</Title>
|
||||
{chartsLoading ? (
|
||||
<Group align="center" gap="lg" wrap="nowrap">
|
||||
<Skeleton circle height={180} width={180} />
|
||||
<Stack gap="sm" flex={1}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} height={14} radius="sm" />
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
) : (
|
||||
<StatusDonut applications={applications} />
|
||||
)}
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper p="lg" shadow="sm" withBorder>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{t('dashboard.recentApplications')}</Title>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/applications')}
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Button>
|
||||
</Group>
|
||||
<ApplicationsTable applications={recent} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user