Merge branch 'feature/pencil-design' of github.com:Tria-plc/emaui into feature/pencil-design

This commit is contained in:
mengstabketemaw
2026-06-15 13:32:05 +03:00
8 changed files with 15113 additions and 64 deletions

View File

@@ -16,7 +16,16 @@
"Bash(echo \"=== total errors: $\\(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json 2>&1)",
"Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(JSON.stringify\\(p.scripts,null,2\\)\\)\")",
"Bash(node -e \"let s='';process.stdin.on\\('data',d=>s+=d\\).on\\('end',\\(\\)=>{try{console.log\\(Object.keys\\(JSON.parse\\(s\\).targets||{}\\)\\)}catch\\(e\\){console.log\\('no project.json'\\)}}\\)\")",
"Bash(echo \"EXIT: $?\")"
"Bash(echo \"EXIT: $?\")",
"Bash(cp portal.pen backoffice.pen && ls -la backoffice.pen)",
"Bash(node_modules/.bin/tsc --noEmit -p apps/backoffice/tsconfig.json)",
"Bash(echo \"exit=$?\")",
"Bash(git log *)",
"Bash(grep -rl '_$_1e42\\\\|sfL\\(' --exclude-dir=node_modules --exclude-dir=.git .)",
"Bash(grep -c '_$_1e42')"
],
"additionalDirectories": [
"/home/tria/projects/mengisteab/emaui"
]
}
}

View File

@@ -1,43 +1,437 @@
import type { ElementType } from 'react';
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
import { useState, type ElementType, type ReactNode } from 'react';
import {
Paper,
Text,
Title,
Stack,
Group,
SimpleGrid,
Grid,
ThemeIcon,
Badge,
Avatar,
SegmentedControl,
Button,
Box,
UnstyledButton,
Anchor,
} from '@mantine/core';
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';
interface StatCardProps {
label: string;
value: string;
icon: ElementType;
/* ------------------------------------------------------------------ */
/* 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' },
];
const KPIS = [
{ label: 'Total Users', value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
{ label: 'Active Users', value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
{ label: 'New This Month', value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
{ label: 'Pending Approvals', value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
];
const QUICK_LINKS = [
{ label: 'Add User', desc: 'Create a new account', icon: IconUserPlus, color: 'blue' },
{ label: 'Roles & Permissions', desc: 'Manage access levels', icon: IconShieldLock, color: 'indigo' },
{ label: 'Invite Members', desc: 'Send email invites', icon: IconMail, color: 'cyan' },
{ label: 'Import Users', desc: 'Bulk CSV import', icon: IconUpload, color: 'violet' },
{ label: 'Export Data', desc: 'Download reports', icon: IconDownload, color: 'blue' },
{ label: 'Audit Log', desc: 'Review activity', 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' },
];
function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
/* ------------------------------------------------------------------ */
/* small building blocks */
/* ------------------------------------------------------------------ */
function SectionCard({ children }: { children: ReactNode }) {
return (
<Paper p="md" shadow="sm" radius="md" withBorder>
<Group justify="space-between">
<Stack gap={4}>
<Text size="sm" c="dimmed">
{label}
</Text>
<Title order={3}>{value}</Title>
</Stack>
<Icon size={32} color={color} />
</Group>
<Paper p="lg" radius="lg" withBorder h="100%">
{children}
</Paper>
);
}
export function DashboardPage() {
function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack gap="lg">
<Title order={2}>Dashboard</Title>
<Grid>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Items" value="—" icon={IconBox} color="#2563eb" />
<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 */
/* ------------------------------------------------------------------ */
export function DashboardPage() {
const [range, setRange] = useState('week');
return (
<Stack gap="xl">
{/* header */}
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={4}>
<Title order={2}>Overview</Title>
<Text c="dimmed" size="sm">
Welcome back here&apos;s what&apos;s happening across your platform.
</Text>
</Stack>
<Group gap="sm">
<SegmentedControl
value={range}
onChange={setRange}
radius="md"
data={[
{ label: 'Today', value: 'today' },
{ label: 'This Week', value: 'week' },
{ label: 'This Month', value: 'month' },
]}
/>
<Button leftSection={<IconDownload size={16} />} radius="md">
Export
</Button>
</Group>
</Group>
{/* KPIs */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{KPIS.map((k) => (
<StatCard key={k.label} {...k} />
))}
</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="User Registrations"
subtitle="New sign-ups over the last 8 months"
/>
<Stack gap={0} align="flex-end">
<Text fw={700} c="teal">
+18.2%
</Text>
<Text size="xs" c="dimmed">
vs previous period
</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, sm: 4 }}>
<StatCard label="Total Users" value="—" icon={IconUsers} color="#16a34a" />
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<CardHeading title="Users by Role" subtitle="Distribution across access levels" />
<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' }}
>
<Text fw={800} fz={22}>
9,431
</Text>
<Text size="xs" c="dimmed">
Total users
</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}%
</Text>
</Group>
))}
</Stack>
</Group>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Activity" value="—" icon={IconActivity} color="#d97706" />
</Grid>
{/* quick links + recent users */}
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<CardHeading title="Quick Links" subtitle="Common administrative shortcuts" />
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md" mt="lg">
{QUICK_LINKS.map((q) => (
<QuickLinkTile key={q.label} {...q} />
))}
</SimpleGrid>
</SectionCard>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">
Recent Users
</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
View all
<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>

View File

@@ -9,9 +9,10 @@ export function BackofficeLayout() {
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 240, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
header={{ height: 64 }}
navbar={{ width: 264, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="lg"
styles={{ main: { backgroundColor: '#f5f7fb' } }}
>
<AppShell.Header>
<AppHeader onToggle={toggle} />

View File

@@ -1,5 +1,22 @@
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
import { IconLogout } from '@tabler/icons-react';
import {
Group,
Text,
ActionIcon,
Burger,
TextInput,
Avatar,
Menu,
Indicator,
UnstyledButton,
} from '@mantine/core';
import {
IconLogout,
IconBell,
IconSearch,
IconChevronDown,
IconUserCircle,
IconSettings,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { logout } from '@ema-platform/auth';
@@ -18,16 +35,54 @@ export function AppHeader({ onToggle }: AppHeaderProps) {
};
return (
<Group h="100%" px="md" justify="space-between">
<Group>
<Group h="100%" px="lg" justify="space-between">
<Group gap="sm">
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
<Text fw={700} size="lg">
EMA Backoffice
</Text>
<TextInput
visibleFrom="sm"
w={320}
radius="md"
placeholder="Search users, roles..."
leftSection={<IconSearch size={16} />}
/>
</Group>
<ActionIcon variant="subtle" onClick={handleLogout} title="Logout">
<IconLogout size={18} />
<Group gap="sm">
<Indicator color="red" size={8} offset={6} withBorder>
<ActionIcon variant="subtle" color="gray" radius="xl" size="lg" aria-label="Notifications">
<IconBell size={19} />
</ActionIcon>
</Indicator>
<Menu position="bottom-end" width={200} shadow="md">
<Menu.Target>
<UnstyledButton>
<Group gap="sm" wrap="nowrap">
<Avatar color="blue" radius="xl" size={36}>
AD
</Avatar>
<div style={{ lineHeight: 1.1 }}>
<Text size="sm" fw={600} visibleFrom="sm">
Amanuel D.
</Text>
<Text size="xs" c="dimmed" visibleFrom="sm">
Administrator
</Text>
</div>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconUserCircle size={16} />}>Profile</Menu.Item>
<Menu.Item leftSection={<IconSettings size={16} />}>Settings</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconLogout size={16} />} onClick={handleLogout}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}

View File

@@ -1,17 +1,15 @@
import { NavLink, Stack } from '@mantine/core';
import { Stack, Group, Text, NavLink, ThemeIcon, Button, Box } from '@mantine/core';
import {
IconDashboard,
IconBox,
IconLayoutDashboard,
IconUsers,
IconClipboardList,
IconShieldCheck,
IconLifebuoy,
} from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
const NAV_ITEMS = [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Items', icon: IconBox, path: '/items' },
{ label: 'Dashboard', icon: IconLayoutDashboard, path: '/dashboard' },
{ label: 'User Management', icon: IconUsers, path: '/um' },
{ label: 'Audit Log', icon: IconClipboardList, path: '/audit-log' },
];
export function AppSidebar() {
@@ -19,16 +17,77 @@ export function AppSidebar() {
const { pathname } = useLocation();
return (
<Stack p="xs" gap={4}>
<Stack h="100%" justify="space-between" p="md" gap="lg">
<Stack gap="lg">
{/* brand */}
<Group gap="sm" px={4} wrap="nowrap">
<ThemeIcon size={42} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'indigo', deg: 135 }}>
<IconShieldCheck size={22} />
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
EMA Admin
</Text>
<Text size="xs" c="dimmed">
Control Center
</Text>
</div>
</Group>
{/* menu */}
<div>
<Text size="xs" fw={700} c="dimmed" px="xs" mb={8} style={{ letterSpacing: 1 }}>
MENU
</Text>
<Stack gap={4}>
{NAV_ITEMS.map(({ label, icon: Icon, path }) => (
<NavLink
key={path}
label={label}
leftSection={<Icon size={18} />}
leftSection={<Icon size={19} />}
active={pathname.startsWith(path)}
onClick={() => navigate(path)}
variant="light"
color="blue"
styles={{ root: { borderRadius: 10 }, label: { fontWeight: 500 } }}
/>
))}
</Stack>
</div>
</Stack>
{/* help card */}
<Box
style={{
borderRadius: 16,
padding: 18,
color: '#fff',
background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
}}
>
<Box
w={36}
h={36}
style={{
borderRadius: 10,
background: 'rgba(255,255,255,0.18)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconLifebuoy size={20} />
</Box>
<Text fw={700} mt="sm">
Need help?
</Text>
<Text size="xs" mt={4} style={{ opacity: 0.85 }}>
Browse guides or contact support.
</Text>
<Button fullWidth mt="md" size="xs" radius="md" variant="white" color="blue">
Open Help Center
</Button>
</Box>
</Stack>
);
}

View File

@@ -1,4 +1,9 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import {
createBrowserRouter,
RouterProvider,
Navigate,
Outlet,
} from 'react-router-dom';
import {
LoginPage,
ForgotPasswordPage,
@@ -10,6 +15,15 @@ import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
/** Wraps the authenticated area in the IAM provider stack. */
function IamShell() {
return (
<IamProviders>
<Outlet />
</IamProviders>
);
}
const router = createBrowserRouter([
{
element: <AuthLayout />,
@@ -21,16 +35,23 @@ const router = createBrowserRouter([
},
{
element: <ProtectedRoute />,
children: [
{
element: <IamShell />,
children: [
{
element: <BackofficeLayout />,
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
],
},
// User-management module runs full-screen (its own chrome), outside the shell.
{ path: 'um/*', element: <UserManagementHostPage /> },
],
},
],
},
{ path: '/um/*', element: <UserManagementHostPage /> },
{ path: '/404', element: <div>Page not found</div> },
{ path: '*', element: <Navigate to="/404" replace /> },
]);

7255
backoffice-v2.pen Normal file

File diff suppressed because it is too large Load Diff

7255
backoffice.pen Normal file

File diff suppressed because it is too large Load Diff