frontend scalfolding

This commit is contained in:
Mengisteab
2026-06-10 07:03:50 +00:00
parent 4faf851a8f
commit 86edf2b971
40 changed files with 3525 additions and 79 deletions

View File

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

View File

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

View File

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

View File

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